Compare commits

..

41 commits

Author SHA1 Message Date
31b590a542 WIP checkpoint
abandoning this though
2023-07-07 22:07:41 -07:00
973e7f34da Fix typings and output with remixes 2023-07-07 14:32:03 -07:00
d0535d6031 Fix bugs relating to [party] 2023-07-07 07:00:44 -07:00
b2f64f1d78 Fix transformers and types
Dozens of tiny errors from me freehanding it
2023-07-07 06:47:41 -07:00
d42927623e Update all files with new object structure 2023-07-07 05:43:02 -07:00
500b7ffbbf Add the last transformers 2023-07-06 23:57:25 -07:00
f73787c23d Add transformer for Job classes 2023-07-06 23:48:32 -07:00
fe32a49bc4 Add transformer for GridSummon 2023-07-06 23:37:10 -07:00
8ef2bf76e3 Add transformer for GridWeapon 2023-07-06 23:33:51 -07:00
7cd87bd4a7 Add transformer for GridCharacter 2023-07-06 23:20:27 -07:00
511d7ee0ec Fix comments 2023-07-06 23:20:09 -07:00
2838622335 Add transformers for core entities
canonical Characters, Weapons and Summons
2023-07-06 22:59:45 -07:00
587153a9e2 Update User.d.ts 2023-07-06 22:55:58 -07:00
7224ae8585 Make signatures consistent 2023-07-06 22:55:10 -07:00
43ccb464b1 Merge branch 'staging' into transformers 2023-07-06 22:35:56 -07:00
686d0d0642 Disable tab pages 2023-07-06 22:26:43 -07:00
af9064a356 Some fixes for scrollable dialogs on mobile
This is 100% not going to scale to devices that are not my iPhone 14 Pro Max, but I can't get env variables working in CSS and something is better than nothing for right now.
2023-07-06 19:22:11 -07:00
2049ad4cf7 Put viewport meta tag in _app 2023-07-06 19:21:35 -07:00
14994bfbbd Fix TableField components on mobile 2023-07-06 18:37:44 -07:00
8dbc6c1c6c Small fix for some modals on mobile
This fixes the slide up animation and the end point so that modals are actually visible on mobile. Ones that scroll still don't work great.
2023-07-06 18:37:30 -07:00
11577a6b61 Ensure name modification works right
Needed a null check? for some reason?
2023-07-06 17:08:37 -07:00
abd98d27c9 Add placeholder to party description 2023-07-06 17:07:05 -07:00
17113e2ad9 Add placeholder extension 2023-07-06 16:58:37 -07:00
5e0bda987d Show localized placeholder for team name 2023-07-06 16:58:29 -07:00
da1acb1463 Allow translation of Heading icons 2023-07-06 16:58:22 -07:00
93e4fd74fd Add toolbar localizations 2023-07-06 16:58:07 -07:00
bed7d0d408
Update transcendence components to work with CSS modules (#350)
* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.
2023-07-06 15:56:23 -07:00
a70c09b373 Fix styles 2023-07-06 03:33:07 -07:00
5b42ca862e Remove duplicate binding 2023-07-06 03:08:12 -07:00
78ae6f2fd1
Merge branch 'main' into staging 2023-07-06 03:06:39 -07:00
8ea2f97aac Fix icon path 2023-07-06 03:05:31 -07:00
8f7670c07b
Merge branch 'main' into staging 2023-07-06 02:55:46 -07:00
83bebdb0c2
Tiptap updates (#343)
* Reinstantiate editor on changes

We can't dynamically update the content, so we have to recreate the Editor whenever something changes (page loads and updates)

* Fix import

@tiptap/core is different than @tiptap/react, who knew

* Added several Tiptap components

* Added a Remix icon that isn't in remixicon-react

* Add colors for highlights

* Add ToolbarButton component

This is to standardize adding Toolbar icons so it wasn't a miserable mess in the Editor file

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

We always want to use the party in props until the transformer work is done and our source of truth is more reliable.

Also, we are using keys to ensure that the component reloads on new page.

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

Here, we do some tactical bandaid fixes to ensure that when the user saves data to the server, the editor will show the freshest data in both editable and read-only mode.

In the Editor, its as easy as calling the setContent command in a useEffect hook when the content changes.

In the party, we are saving party in a state and passing it down to the components via props. This is because the party prop we get from pages is only from the first time the server loaded data, so any edits are not reflected. The app state should have the latest updates, but due to reasons I don't completely understand, it is showing the old state first and then the one we want, causing the Editor to get stuck on old data.

By storing the party in a state, we can populate the state from the server when the component mounts, then update it whenever the user saves data since all party data is saved in that component.

* Fix build errors
2023-07-06 02:51:01 -07:00
7c814610b9 Create PartyTransformer
Transforms data into Party objects and back again.

We also created PartyParams to send data back to the API in a uniform way.

We organized the resulting object more than we have in the past since we can do what we want now.

Lastly, we removed characters, weapons and summons from this object. We will probably make a new Grid object and reference that here instead.
2023-07-06 00:04:52 -07:00
881ed31dd1 Create UserTransformer
Transforms data into User objects and back again.

We added the UserParams interface to handle sending data back to the API in a uniform manner.

We also modified the User object to store the user's language and theme for simplicity's sake, since the app state wants this information today.
2023-07-06 00:03:21 -07:00
f5ee806f8b Create RaidTransformer
Transforms data into Raid objects. Also, updated Raid type to use GranblueElement.
2023-07-06 00:02:11 -07:00
6ab2c2488d Create RaidGroupTransformer
Transforms data into RaidGroup objects
2023-07-06 00:01:36 -07:00
cb4fd491ac Create ElementTransformer
Transforms elements from numbers into objects and back again
2023-07-06 00:01:17 -07:00
9adcd50519 Fix background-color on CharacterRep 2023-07-05 22:35:54 -07:00
955cd14762 Override peer dependencies for tiptap mentions
They haven't fixed the suggestion plugin, so we have to use a beta version
2023-07-05 21:43:03 -07:00
15a32d56bb
Rich text editor and support for tagging objects (#340)
* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

We renamed the folders for character images from `chara-` to `character-`

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

This commit adds the bulk of the code for our new rich-text editor. The Editor component will be used to edit and display rich text via Tiptap.

* Add mention components

This adds the code required for us to mention objects in rich text fields like team descriptions.

The mentionSuggestion util fetches data from the server and serves it to MentionList for the user to select, then inserts it into the Editor as a token.

* Implements Editor in edit team and team footer

This implements the Editor component in EditPartyModal and PartyFooter. In PartyFooter, it is read-only.

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

Old descriptions just translate as a blob of text, so we try to insert some paragraphs and newlines to keep things presentable and lessen the load if users decide to update

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

This shouldn't work? The snapshot should be the reactive one? I don't fucking know

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors
2023-07-05 21:19:34 -07:00
402 changed files with 8456 additions and 19536 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

6
.gitignore vendored
View file

@ -59,8 +59,6 @@ public/images/updates*
public/images/guidebooks* public/images/guidebooks*
public/images/raids* public/images/raids*
public/images/gacha* public/images/gacha*
public/images/previews*
public/image/profiles*
# Typescript v1 declaration files # Typescript v1 declaration files
typings/ typings/
@ -88,7 +86,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,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

@ -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,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,29 +1,31 @@
'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next' import { useRouter } from 'next/router'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import * as ElementTransformer from '~transformers/ElementTransformer'
import * as ToggleGroup from '@radix-ui/react-toggle-group' import * as ToggleGroup from '@radix-ui/react-toggle-group'
import styles from './index.module.scss' import styles from './index.module.scss'
interface Props { interface Props {
currentElement: number currentElement: GranblueElement
sendValue: (value: number) => void sendValue: (value: GranblueElement) => void
} }
const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => { const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
// Localization // Router and localization
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const t = useTranslations('common') const { t } = useTranslation('common')
// State: Component // State: Component
const [element, setElement] = useState(currentElement) const [element, setElement] = useState(currentElement)
// Methods: Handlers // Methods: Handlers
const handleElementChange = (value: string) => { const handleElementChange = (value: string) => {
const newElement = parseInt(value) const newElement = ElementTransformer.toObject(parseInt(value))
setElement(newElement) setElement(newElement)
sendValue(newElement) sendValue(newElement)
} }

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import Button from '~components/common/Button' import Button from '~components/common/Button'
import { ResponseStatus } from '~types' import { ResponseStatus } from '~types'
@ -13,7 +13,7 @@ interface Props {
const ErrorSection = ({ status }: Props) => { const ErrorSection = ({ status }: Props) => {
// Import translations // Import translations
const t = useTranslations('common') const { t } = useTranslation('common')
const [statusText, setStatusText] = useState('') const [statusText, setStatusText] = useState('')

View file

@ -0,0 +1,226 @@
.gridRep {
aspect-ratio: 3/2;
border: 1px solid transparent;
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr 1fr;
gap: $unit;
padding: $unit-2x;
min-width: 320px;
width: 100%;
&:hover {
background: var(--grid-rep-hover);
border: 1px solid rgba(0, 0, 0, 0.1);
a {
text-decoration: none;
}
.weaponGrid {
cursor: pointer;
.weapon {
background: var(--unit-bg-hover);
}
}
@include breakpoint(phone) {
background: inherit;
.Grid .Weapon {
box-shadow: none;
}
}
}
& > .weaponGrid {
aspect-ratio: 2/0.95;
display: grid;
grid-template-columns: 1fr 3.36fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit; /* add a gap of 8px between grid items */
.weapon {
background: var(--unit-bg);
border-radius: 4px;
}
.mainhand.weapon {
aspect-ratio: 73/153;
display: grid;
grid-column: 1 / 2; /* spans one column */
height: calc(100% - $unit-fourth);
}
.weapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit;
// column-gap: $unit;
// row-gap: $unit-2x;
}
.grid.weapon {
aspect-ratio: 280 / 160;
display: grid;
}
.mainhand.weapon img[src*='jpg'],
.grid.weapon img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}
.details {
display: flex;
flex-direction: column;
gap: $unit;
h2 {
color: var(--text-primary);
font-size: $font-regular;
font-weight: $bold;
overflow: hidden;
padding-bottom: 1px;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 258px; // Can we not do this?
&.empty {
color: var(--text-tertiary);
}
}
.top {
display: flex;
flex-direction: row;
gap: calc($unit / 2);
align-items: center;
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: calc($unit / 2);
}
button svg {
width: 14px;
height: 14px;
}
}
.attributed,
.bottom {
display: flex;
gap: $unit-half;
justify-content: space-between;
a.user:hover {
color: var(--link-text-hover);
}
}
.bottom {
flex-direction: column;
}
.user {
flex-grow: 1;
}
.user,
.raid,
time {
color: var(--text-tertiary);
font-size: $font-small;
}
time {
white-space: nowrap;
}
.properties {
display: flex;
font-size: $font-small;
gap: $unit-half;
.raid {
white-space: nowrap;
text-overflow: ellipsis;
}
.auto {
flex: 1;
display: inline-flex;
gap: $unit-half;
flex-direction: row;
flex-wrap: nowrap;
}
.fullAuto {
color: var(--full-auto-label-text);
white-space: nowrap;
}
.extra {
color: var(--extra-purple-light-text);
white-space: nowrap;
}
.autoGuard {
display: inline-block;
width: 12px;
height: 12px;
svg {
fill: var(--full-auto-label-text);
}
}
}
.raid {
color: var(--text-primary);
&.empty {
color: var(--text-tertiary);
}
}
.user {
display: flex;
gap: calc($unit / 2);
align-items: center;
img,
.no-user {
$diameter: 18px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
img.gran {
background-color: #cee7fe;
}
img.djeeta {
background-color: #ffe1fe;
}
.no-user {
background: $grey-80;
}
}
}
}

View file

@ -0,0 +1,261 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import 'fix-date'
import { accountState } from '~utils/accountState'
import { formatTimeAgo } from '~utils/timeAgo'
import { ElementMap } from '~utils/elements'
import { mapToGridArray } from '~utils/mapToGridArray'
import Button from '~components/common/Button'
import SaveIcon from '~public/icons/Save.svg'
import ShieldIcon from '~public/icons/Shield.svg'
import styles from './index.module.scss'
interface Props {
shortcode: string
id: string
name: string
raid: Raid | null
weapons: {
mainWeapon: GridWeapon | null
allWeapons: GridArray<GridWeapon> | null
} | null
user: User | null
fullAuto: boolean
autoGuard: boolean
favorited: boolean
createdAt: Date
onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void
}
const GridRep = (props: Props) => {
const numWeapons: number = 9
const { account } = useSnapshot(accountState)
const router = useRouter()
const { t } = useTranslation('common')
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [grid, setGrid] = useState<GridArray<GridWeapon>>({})
const titleClass = classNames({
empty: !props.name,
})
const raidClass = classNames({
[styles.raid]: true,
[styles.empty]: !props.raid,
})
const userClass = classNames({
[styles.user]: true,
[styles.empty]: !props.user,
})
const mainhandClasses = classNames({
[styles.weapon]: true,
[styles.mainhand]: true,
})
const weaponClasses = classNames({
[styles.weapon]: true,
[styles.grid]: true,
})
useEffect(() => {
if (props.weapons && props.weapons.mainWeapon) {
setMainhand(props.weapons.mainWeapon?.object)
}
if (props.weapons && props.weapons.allWeapons) {
setWeapons(
mapToGridArray(
Object.values(props.weapons.allWeapons).map((w) => w?.object)
)
)
setGrid(props.weapons.allWeapons)
}
}, [props.weapons])
function navigate() {
props.onClick(props.shortcode)
}
function generateMainhandImage() {
let url = ''
if (mainhand) {
const weapon = props.weapons?.mainWeapon
if (mainhand.element === ElementMap.null && weapon && weapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblueId}_${weapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblueId}.jpg`
}
}
return mainhand && <img alt={mainhand.name[locale]} src={url} />
}
function generateGridImage(position: number) {
let url = ''
const weapon = weapons[position]
const gridWeapon = grid[position]
if (weapon && gridWeapon) {
if (weapon.element === ElementMap.null && gridWeapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblueId}_${gridWeapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblueId}.jpg`
}
}
return (
weapons[position] && (
<img alt={weapons[position]?.name[locale]} src={url} />
)
)
}
function sendSaveData() {
if (props.onSave) props.onSave(props.id, props.favorited)
}
const userImage = () => {
if (props.user && props.user.avatar) {
return (
<img
alt={props.user.avatar.picture}
className={`profile ${props.user.avatar.element}`}
srcSet={`/profile/${props.user.avatar.picture}.png,
/profile/${props.user.avatar.picture}@2x.png 2x`}
src={`/profile/${props.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`}
/>
)
}
const attribution = () => (
<span className={userClass}>
{userImage()}
{props.user ? props.user.username : t('no_user')}
</span>
)
function fullAutoString() {
const fullAutoElement = (
<span className={styles.fullAuto}>
{` · ${t('party.details.labels.full_auto')}`}
</span>
)
const autoGuardElement = (
<span className={styles.autoGuard}>
<ShieldIcon />
</span>
)
return (
<div className={styles.auto}>
{fullAutoElement}
{props.autoGuard ? autoGuardElement : ''}
</div>
)
}
const detailsWithUsername = (
<div className={styles.details}>
<div className={styles.top}>
<div className={styles.info}>
<h2 className={titleClass}>
{props.name ? props.name : t('no_title')}
</h2>
<div className={styles.properties}>
<span className={raidClass}>
{props.raid ? props.raid.name[locale] : t('no_raid')}
</span>
{props.fullAuto && (
<span className={styles.fullAuto}>
{` · ${t('party.details.labels.full_auto')}`}
</span>
)}
{props.raid && props.raid.group?.extra && (
<span className={styles.extra}>{` · EX`}</span>
)}
</div>
</div>
{account.authorized &&
((props.user && account.user && account.user.id !== props.user.id) ||
!props.user) && (
<Link href="#">
<Button
className={classNames({
save: true,
saved: props.favorited,
})}
leftAccessoryIcon={<SaveIcon className="stroke" />}
active={props.favorited}
bound={true}
size="small"
onClick={sendSaveData}
/>
</Link>
)}
</div>
<div className={styles.attributed}>
{attribution()}
<time
className={styles.lastUpdated}
dateTime={props.createdAt.toISOString()}
>
{formatTimeAgo(props.createdAt, locale)}
</time>
</div>
</div>
)
return (
<Link href={`/p/${props.shortcode}`}>
<a className={styles.gridRep}>
{detailsWithUsername}
<div className={styles.weaponGrid}>
<div className={mainhandClasses}>{generateMainhandImage()}</div>
<ul className={styles.weapons}>
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`${props.shortcode}-${i}`} className={weaponClasses}>
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
</a>
</Link>
)
}
export default GridRep

View file

@ -1,31 +1,3 @@
.bahamut {
$negative-margin: $unit * -2;
align-items: center;
background: #2b4683;
box-sizing: border-box;
display: flex;
gap: $unit;
justify-content: center;
text-align: center;
font-weight: $bold;
padding: $unit-2x;
margin-top: $negative-margin;
margin-left: $negative-margin;
margin-right: $negative-margin;
margin-bottom: $unit-2x;
width: 100vw;
p {
color: white;
}
svg {
width: 1.2em;
fill: white;
}
}
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -1,9 +1,7 @@
'use client'
import React, { useState } from 'react' import React, { useState } from 'react'
import { deleteCookie, getCookie } from 'cookies-next' import { deleteCookie } from 'cookies-next'
import { useTranslations } from 'next-intl' import { useRouter } from 'next/router'
import { useRouter } from '~/i18n/navigation' import { useTranslation } from 'next-i18next'
import { useSnapshot } from 'valtio'
import classNames from 'classnames' import classNames from 'classnames'
import clonedeep from 'lodash.clonedeep' import clonedeep from 'lodash.clonedeep'
import Link from 'next/link' import Link from 'next/link'
@ -28,7 +26,6 @@ import AccountModal from '~components/auth/AccountModal'
import Button from '~components/common/Button' import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip' import Tooltip from '~components/common/Tooltip'
import BahamutIcon from '~public/icons/Bahamut.svg'
import ChevronIcon from '~public/icons/Chevron.svg' import ChevronIcon from '~public/icons/Chevron.svg'
import MenuIcon from '~public/icons/Menu.svg' import MenuIcon from '~public/icons/Menu.svg'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
@ -37,14 +34,12 @@ import styles from './index.module.scss'
const Header = () => { const Header = () => {
// Localization // Localization
const t = useTranslations('common') const { t } = useTranslation('common')
// Router
const router = useRouter() const router = useRouter()
const locale =
// Locale router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
// Subscribe to account state changes
const accountSnap = useSnapshot(accountState)
// State management // State management
const [alertOpen, setAlertOpen] = useState(false) const [alertOpen, setAlertOpen] = useState(false)
@ -101,7 +96,7 @@ const Header = () => {
if (key !== 'language') accountState[key] = resetState[key] if (key !== 'language') accountState[key] = resetState[key]
}) })
router.refresh() router.reload()
return false return false
} }
@ -113,13 +108,13 @@ const Header = () => {
}) })
// Push the root URL // Push the root URL
router.push('/new') router.push('/new', undefined, { shallow: true })
} }
// Methods: Rendering // Methods: Rendering
const profileImage = () => { const profileImage = () => {
const user = accountSnap.account.user const user = accountState.account.user
if (accountSnap.account.authorized && user) { if (accountState.account.authorized && user) {
return ( return (
<img <img
alt={user.username} alt={user.username}
@ -132,7 +127,7 @@ const Header = () => {
} else { } else {
return ( return (
<img <img
alt={t('header.anonymous')} alt={t('no_user')}
className={`profile anonymous`} className={`profile anonymous`}
srcSet={`/profile/npc.png, srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`} /profile/npc@2x.png 2x`}
@ -169,20 +164,14 @@ const Header = () => {
const settingsModal = ( const settingsModal = (
<> <>
{accountSnap.account.user && ( {accountState.account.user && (
<AccountModal <AccountModal
open={settingsModalOpen} open={settingsModalOpen}
username={accountSnap.account.user.username} username={accountState.account.user.username}
picture={accountSnap.account.user.avatar.picture} picture={accountState.account.user.avatar.picture}
gender={accountSnap.account.user.gender} gender={accountState.account.user.gender}
language={accountSnap.account.user.language} language={accountState.account.user.language}
theme={accountSnap.account.user.theme} theme={accountState.account.user.theme}
role={accountSnap.account.user.role}
bahamutMode={
accountSnap.account.user.role === 9
? accountSnap.account.user.bahamut
: false
}
onOpenChange={setSettingsModalOpen} onOpenChange={setSettingsModalOpen}
/> />
)} )}
@ -200,12 +189,13 @@ const Header = () => {
// Rendering: Compositing // Rendering: Compositing
const authorizedLeftItems = ( const authorizedLeftItems = (
<> <>
{accountSnap.account.user && ( {accountState.account.user && (
<> <>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={closeLeftMenu}> <DropdownMenuItem onClick={closeLeftMenu}>
<Link <Link
href={`/${accountSnap.account.user.username}` || ''} href={`/${accountState.account.user.username}` || ''}
passHref
> >
<span>{t('menu.profile')}</span> <span>{t('menu.profile')}</span>
</Link> </Link>
@ -220,8 +210,8 @@ const Header = () => {
) )
const leftMenuItems = ( const leftMenuItems = (
<> <>
{accountSnap.account.authorized && {accountState.account.authorized &&
accountSnap.account.user && accountState.account.user &&
authorizedLeftItems} authorizedLeftItems}
<DropdownMenuGroup> <DropdownMenuGroup>
@ -292,15 +282,16 @@ const Header = () => {
const authorizedRightItems = ( const authorizedRightItems = (
<> <>
{accountSnap.account.user && ( {accountState.account.user && (
<> <>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel> <DropdownMenuLabel>
{`@${accountSnap.account.user.username}`} {`@${accountState.account.user.username}`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem onClick={closeRightMenu}> <DropdownMenuItem onClick={closeRightMenu}>
<Link <Link
href={`/${accountSnap.account.user.username}` || ''} href={`/${accountState.account.user.username}` || ''}
passHref
> >
<span>{t('menu.profile')}</span> <span>{t('menu.profile')}</span>
</Link> </Link>
@ -353,7 +344,7 @@ const Header = () => {
const rightMenuItems = ( const rightMenuItems = (
<> <>
{accountSnap.account.authorized && accountSnap.account.user {accountState.account.authorized && accountState.account.user
? authorizedRightItems ? authorizedRightItems
: unauthorizedRightItems} : unauthorizedRightItems}
</> </>
@ -384,13 +375,6 @@ const Header = () => {
) )
return ( return (
<>
{accountSnap.account.user?.bahamut && (
<div className={styles.bahamut}>
<BahamutIcon />
<p>Bahamut Mode is active</p>
</div>
)}
<nav className={styles.header}> <nav className={styles.header}>
{left} {left}
{right} {right}
@ -399,7 +383,6 @@ const Header = () => {
{loginModal} {loginModal}
{signupModal} {signupModal}
</nav> </nav>
</>
) )
} }

View file

@ -41,19 +41,17 @@
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; gap: $unit * 2;
gap: $unit-2x;
.icons { .icons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-grow: 0; flex-grow: 1;
gap: $unit-half;
.proficiencies {
display: flex;
gap: $unit; gap: $unit;
} }
.UncapIndicator {
min-width: 100px;
} }
} }
} }

View file

@ -1,11 +1,10 @@
'use client' import { useRouter } from 'next/router'
import { getCookie } from 'cookies-next'
import UncapIndicator from '~components/uncap/UncapIndicator' import UncapIndicator from '~components/uncap/UncapIndicator'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon' import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
import { ElementMap } from '~utils/elements'
import styles from './index.module.scss' import styles from './index.module.scss'
import classNames from 'classnames'
interface Props { interface Props {
gridObject: GridCharacter | GridSummon | GridWeapon gridObject: GridCharacter | GridSummon | GridWeapon
@ -29,15 +28,18 @@ const Proficiency = [
] ]
const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => { const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const overlay = () => { const overlay = () => {
if (type === 'character') { if (type === 'character') {
const gridCharacter = gridObject as GridCharacter const gridCharacter = gridObject as GridCharacter
if (gridCharacter.perpetuity) return <i className={styles.perpetuity} /> if (gridCharacter.mastery.perpetuity)
return <i className={styles.perpetuity} />
} else if (type === 'summon') { } else if (type === 'summon') {
const gridSummon = gridObject as GridSummon const gridSummon = gridObject as GridSummon
if (gridSummon.quick_summon) return <i className={styles.quickSummon} /> if (gridSummon.quickSummon) return <i className={styles.quickSummon} />
} }
} }
@ -47,11 +49,11 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
// Change the image based on the uncap level // Change the image based on the uncap level
let suffix = '01' let suffix = '01'
if (gridCharacter.uncap_level == 6) suffix = '04' if (gridCharacter.uncapLevel == 6) suffix = '04'
else if (gridCharacter.uncap_level == 5) suffix = '03' else if (gridCharacter.uncapLevel == 5) suffix = '03'
else if (gridCharacter.uncap_level > 2) suffix = '02' else if (gridCharacter.uncapLevel > 2) suffix = '02'
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_${suffix}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblueId}_${suffix}.jpg`
} }
const summonImage = () => { const summonImage = () => {
@ -71,29 +73,29 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
let suffix = '' let suffix = ''
if ( if (
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 && upgradedSummons.indexOf(summon.granblueId.toString()) != -1 &&
gridSummon.uncap_level == 5 gridSummon.uncapLevel == 5
) { ) {
suffix = '_02' suffix = '_02'
} else if ( } else if (
gridSummon.object.uncap.transcendence && gridSummon.object.uncap.xlb &&
gridSummon.transcendence_step > 0 gridSummon.transcendenceStep > 0
) { ) {
suffix = '_03' suffix = '_03'
} }
// Generate the correct source for the summon // Generate the correct source for the summon
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblueId}${suffix}.jpg`
} }
const weaponImage = () => { const weaponImage = () => {
const gridWeapon = gridObject as GridWeapon const gridWeapon = gridObject as GridWeapon
const weapon = object as Weapon const weapon = object as Weapon
if (gridWeapon.object.element == 0 && gridWeapon.element) if (gridWeapon.object.element === ElementMap.null && gridWeapon.element)
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblueId}_${gridWeapon.element}.jpg`
else else
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblueId}.jpg`
} }
const image = () => { const image = () => {
@ -107,61 +109,6 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
} }
} }
const summonProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
</div>
)
const weaponProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && !Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency]}
size="small"
/>
)}
</div>
)
const characterProficiency = (
<div
className={classNames({
[styles.icons]: true,
})}
>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[0]]}
size="small"
/>
)}
{'proficiency' in object &&
Array.isArray(object.proficiency) &&
object.proficiency.length > 1 && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[1]]}
size="small"
/>
)}
</div>
)
function proficiency() {
switch (type) {
case 'character':
return characterProficiency
case 'summon':
return summonProficiency
case 'weapon':
return weaponProficiency
}
}
return ( return (
<header className={styles.root}> <header className={styles.root}>
<div className={styles.title}> <div className={styles.title}>
@ -172,16 +119,26 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
</div> </div>
</div> </div>
<div className={styles.subInfo}> <div className={styles.subInfo}>
{proficiency()} <div className={styles.icons}>
<WeaponLabelIcon labelType={object.element.slug} />
{'proficiency' in object && Array.isArray(object.proficiency) && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency[0]]} />
)}
{'proficiency' in object && !Array.isArray(object.proficiency) && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency]} />
)}
{'proficiency' in object &&
Array.isArray(object.proficiency) &&
object.proficiency.length > 1 && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency[1]]} />
)}
</div>
<UncapIndicator <UncapIndicator
className="hovercard"
type={type} type={type}
ulb={object.uncap.ulb || false} ulb={object.uncap.ulb || false}
flb={object.uncap.flb || false} flb={object.uncap.flb || false}
transcendenceStage={ transcendenceStage={
'transcendence_step' in gridObject 'transcendenceStep' in gridObject ? gridObject.transcendenceStep : 0
? gridObject.transcendence_step
: 0
} }
special={'special' in object ? object.special : false} special={'special' in object ? object.special : false}
/> />

View file

@ -1,7 +1,5 @@
'use client'
import React, { PropsWithChildren, useEffect, useState } from 'react' import React, { PropsWithChildren, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/router'
import { usePathname } from 'next/navigation'
import { setCookie } from 'cookies-next' import { setCookie } from 'cookies-next'
import { retrieveLocaleCookies } from '~utils/retrieveCookies' import { retrieveLocaleCookies } from '~utils/retrieveCookies'
import * as SwitchPrimitive from '@radix-ui/react-switch' import * as SwitchPrimitive from '@radix-ui/react-switch'
@ -16,7 +14,6 @@ export const LanguageSwitch = React.forwardRef<HTMLButtonElement, Props>(
) { ) {
// Router and locale data // Router and locale data
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const localeData = retrieveLocaleCookies() const localeData = retrieveLocaleCookies()
// State // State
@ -33,7 +30,7 @@ export const LanguageSwitch = React.forwardRef<HTMLButtonElement, Props>(
expiresAt.setDate(expiresAt.getDate() + 120) expiresAt.setDate(expiresAt.getDate() + 120)
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt }) setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
router.refresh() router.push(router.asPath, undefined, { locale: language })
} }
return ( return (

View file

@ -1,6 +1,5 @@
'use client'
import { PropsWithChildren, useEffect, useState } from 'react' import { PropsWithChildren, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation' import { useRouter } from 'next/router'
import { add, format } from 'date-fns' import { add, format } from 'date-fns'
import { getCookie } from 'cookies-next' import { getCookie } from 'cookies-next'
@ -12,7 +11,7 @@ import UpdateToast from '~components/toasts/UpdateToast'
interface Props {} interface Props {}
const Layout = ({ children }: PropsWithChildren<Props>) => { const Layout = ({ children }: PropsWithChildren<Props>) => {
const pathname = usePathname() const router = useRouter()
const [updateToastOpen, setUpdateToastOpen] = useState(false) const [updateToastOpen, setUpdateToastOpen] = useState(false)
useEffect(() => { useEffect(() => {
@ -49,7 +48,7 @@ const Layout = ({ children }: PropsWithChildren<Props>) => {
} }
const updateToast = () => { const updateToast = () => {
const path = pathname?.replaceAll('/', '') || '' const path = router.asPath.replaceAll('/', '')
return ( return (
!['about', 'updates', 'roadmap'].includes(path) && !['about', 'updates', 'roadmap'].includes(path) &&
@ -76,7 +75,7 @@ const Layout = ({ children }: PropsWithChildren<Props>) => {
return ( return (
<> <>
{appState.version ? ServerAvailable() : ''} {appState.version && ServerAvailable()}
<main>{children}</main> <main>{children}</main>
</> </>
) )

View file

@ -1,25 +1,24 @@
'use client'
import React, { import React, {
forwardRef, forwardRef,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useState, useState,
} from 'react' } from 'react'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import { getCookie } from 'cookies-next' import { useRouter } from 'next/router'
import { SuggestionProps } from '@tiptap/suggestion' import { SuggestionProps } from '@tiptap/suggestion'
import classNames from 'classnames' import classNames from 'classnames'
import styles from './index.module.scss' import styles from './index.module.scss'
type Props = Pick<SuggestionProps, 'items' | 'command' | 'query'> type Props = Pick<SuggestionProps, 'items' | 'command'>
export type MentionRef = { export type MentionRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean onKeyDown: (props: { event: KeyboardEvent }) => boolean
} }
export type MentionSuggestion = { export type MentionSuggestion = {
granblue_id: string granblueId: string
name: { name: {
[key: string]: string [key: string]: string
en: string en: string
@ -35,9 +34,10 @@ interface MentionProps extends SuggestionProps {
export const MentionList = forwardRef<MentionRef, Props>( export const MentionList = forwardRef<MentionRef, Props>(
({ items, ...props }: Props, forwardedRef) => { ({ items, ...props }: Props, forwardedRef) => {
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const router = useRouter()
const locale = router.locale || 'en'
const t = useTranslations('common') const { t } = useTranslation('common')
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
@ -101,10 +101,10 @@ export const MentionList = forwardRef<MentionRef, Props>(
alt={item.name[locale]} alt={item.name[locale]}
src={ src={
item.type === 'character' item.type === 'character'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}_01.jpg` ? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblueId}_01.jpg`
: item.type === 'job' : item.type === 'job'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblue_id}.png` ? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblueId}.png`
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}.jpg` : `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblueId}.jpg`
} }
/> />
</div> </div>
@ -113,9 +113,7 @@ export const MentionList = forwardRef<MentionRef, Props>(
)) ))
) : ( ) : (
<div className={styles.noResult}> <div className={styles.noResult}>
{props.query.length < 3 {t('search.errors.no_results_generic')}
? t('search.errors.type')
: t('search.errors.no_results_generic')}
</div> </div>
)} )}
</div> </div>

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

View file

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslations } from 'next-intl' import { Trans, useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import LinkItem from '../LinkItem' import LinkItem from '../LinkItem'
@ -12,8 +12,8 @@ import styles from './index.module.scss'
interface Props {} interface Props {}
const AboutPage: React.FC<Props> = (props: Props) => { const AboutPage: React.FC<Props> = (props: Props) => {
const common = useTranslations('common') const { t: common } = useTranslation('common')
const about = useTranslations('about') const { t: about } = useTranslation('about')
const classes = classNames(styles.about, 'PageContent') const classes = classNames(styles.about, 'PageContent')
@ -22,17 +22,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<h1>{common('about.segmented_control.about')}</h1> <h1>{common('about.segmented_control.about')}</h1>
<section> <section>
<h2> <h2>
{about.rich('about.subtitle', { <Trans i18nKey="about:about.subtitle">
gameLink: (chunks) => ( Granblue.team is a tool to save and share team compositions for{' '}
<a <a
href="https://game.granbluefantasy.jp" href="https://game.granbluefantasy.jp"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} Granblue Fantasy
</a> </a>
) , a social RPG from Cygames.
})} </Trans>
</h2> </h2>
<p>{about('about.explanation.0')}</p> <p>{about('about.explanation.0')}</p>
<p>{about('about.explanation.1')}</p> <p>{about('about.explanation.1')}</p>
@ -54,52 +54,53 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<section> <section>
<h2>{about('about.credits.title')}</h2> <h2>{about('about.credits.title')}</h2>
<p> <p>
{about.rich('about.credits.maintainer', { <Trans i18nKey="about:about.credits.maintainer">
link: (chunks) => ( Granblue.team was built and is maintained by{' '}
<a <a
href="https://twitter.com/jedmund" href="https://twitter.com/jedmund"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} @jedmund
</a> </a>
) .
})} </Trans>
</p> </p>
<p> <p>
{about.rich('about.credits.assistance', { <Trans i18nKey="about:about.credits.assistance">
link1: (chunks) => ( Many thanks to{' '}
<a <a
href="https://twitter.com/lalalalinna" href="https://twitter.com/lalalalinna"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} @lalalalinna
</a> </a>{' '}
), and{' '}
link2: (chunks) => (
<a <a
href="https://twitter.com/tarngerine" href="https://twitter.com/tarngerine"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} @tarngerine
</a> </a>
) , who both provided a lot of help and advice as I was ramping up.
})} </Trans>
</p> </p>
<p> <p>
{about.rich('about.credits.support', { <Trans i18nKey="about:about.credits.support">
link: (chunks) => ( Many thanks also go to everyone in{' '}
<a <a
href="https://game.granbluefantasy.jp/#guild/detail/1190185" href="https://game.granbluefantasy.jp/#guild/detail/1190185"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} Fireplace
</a> </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> </p>
</section> </section>
@ -125,17 +126,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<section> <section>
<h2>{about('about.license.title')}</h2> <h2>{about('about.license.title')}</h2>
<p> <p>
{about.rich('about.license.license', { <Trans i18nKey="about:about.license.license">
link: (chunks) => ( This app is licensed under{' '}
<a <a
href="https://choosealicense.com/licenses/agpl-3.0/" href="https://choosealicense.com/licenses/agpl-3.0/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{chunks} GNU AGPLv3
</a> </a>
) .
})} </Trans>
</p> </p>
<p>{about('about.license.explanation')}</p> <p>{about('about.license.explanation')}</p>
</section> </section>

View file

@ -1,12 +1,12 @@
'use client' import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next' import api from '~utils/api'
import styles from './index.module.scss' import styles from './index.module.scss'
interface Props { interface Props {
id: string id: string
type: 'character' | 'summon' | 'weapon' | 'raid' | 'job' type: 'character' | 'summon' | 'weapon'
image?: '01' | '02' | '03' | '04' image?: '01' | '02' | '03' | '04'
} }
@ -19,47 +19,48 @@ const defaultProps = {
} }
const ChangelogUnit = ({ id, type, image }: Props) => { const ChangelogUnit = ({ id, type, image }: Props) => {
// Locale // Router
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// State // State
const [item, setItem] = useState<Character | Weapon | Summon>() const [item, setItem] = useState<Character | Weapon | Summon>()
// Hooks // Hooks
useEffect(() => { useEffect(() => {
fetchItem() fetch()
}, [id, type]) }, [])
async function fetchItem() {
try {
let endpoint = ''
async function fetch() {
switch (type) { switch (type) {
case 'character': case 'character':
endpoint = `/api/characters/${id}` const character = await fetchCharacter()
setItem(character.data)
break break
case 'weapon': case 'weapon':
endpoint = `/api/weapons/${id}` const weapon = await fetchWeapon()
setItem(weapon.data)
break break
case 'summon': case 'summon':
endpoint = `/api/summons/${id}` const summon = await fetchSummon()
setItem(summon.data)
break break
case 'raid': }
endpoint = `/api/raids/${id}`
break
default:
return
} }
const response = await fetch(endpoint) async function fetchCharacter() {
return api.endpoints.characters.getOne({ id: id })
}
if (response.ok) { async function fetchWeapon() {
const data = await response.json() return api.endpoints.weapons.getOne({ id: id })
setItem(data)
}
} catch (error) {
console.error(`Error fetching ${type} ${id}:`, error)
} }
async function fetchSummon() {
return api.endpoints.summons.getOne({ id: id })
} }
const imageUrl = () => { const imageUrl = () => {
@ -70,20 +71,10 @@ const ChangelogUnit = ({ id, type, image }: Props) => {
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${id}_${image}.jpg` src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${id}_${image}.jpg`
break break
case 'weapon': case 'weapon':
src = src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
image === '03'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}_${image}.jpg`
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
break break
case 'summon': case 'summon':
src = src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
image === '04'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}_${image}.jpg`
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
break
case 'raid':
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${id}.png`
break break
} }
@ -92,8 +83,8 @@ const ChangelogUnit = ({ id, type, image }: Props) => {
return ( return (
<div className={styles.unit} key={id}> <div className={styles.unit} key={id}>
<img alt={item ? item.name[locale] : ''} src={imageUrl()} /> <img alt={item && item.name[locale]} src={imageUrl()} />
<h4>{item ? item.name[locale] : ''}</h4> <h4>{item && item.name[locale]}</h4>
</div> </div>
) )
} }

View file

@ -30,8 +30,7 @@
.characters, .characters,
.weapons, .weapons,
.summons, .summons {
.raids {
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
gap: $unit; gap: $unit;

View file

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import ChangelogUnit from '~components/about/ChangelogUnit' import ChangelogUnit from '~components/about/ChangelogUnit'
@ -17,9 +17,6 @@ interface Props {
event: string event: string
newItems?: UpdateObject newItems?: UpdateObject
uncappedItems?: UpdateObject uncappedItems?: UpdateObject
transcendedItems?: UpdateObject
awakenedItems?: string[]
raidItems?: string[]
numNotes: number numNotes: number
} }
const ContentUpdate = ({ const ContentUpdate = ({
@ -28,12 +25,9 @@ const ContentUpdate = ({
event, event,
newItems, newItems,
uncappedItems, uncappedItems,
transcendedItems,
awakenedItems,
raidItems,
numNotes, numNotes,
}: Props) => { }: Props) => {
const updates = useTranslations('updates') const { t: updates } = useTranslation('updates')
const date = new Date(dateString) const date = new Date(dateString)
@ -106,94 +100,6 @@ const ContentUpdate = ({
return section return section
} }
function transcendItemElements(key: 'character' | 'weapon' | 'summon') {
let elements: React.ReactNode[] = []
if (transcendedItems && transcendedItems[key]) {
const items = transcendedItems[key]
elements = items
? items.map((id) => {
return key === 'character' || key === 'summon' ? (
<ChangelogUnit id={id} type={key} key={id} image="04" />
) : (
<ChangelogUnit id={id} type={key} key={id} image="03" />
)
})
: []
}
return elements
}
function transcendItemSection(key: 'character' | 'weapon' | 'summon') {
let section: React.ReactNode = ''
if (transcendedItems && transcendedItems[key]) {
const items = transcendedItems[key]
section =
items && items.length > 0 ? (
<section className={styles[`${key}s`]}>
<h4>{updates(`labels.transcends.${key}s`)}</h4>
<div className={styles.items}>{transcendItemElements(key)}</div>
</section>
) : (
''
)
}
return section
}
function newRaidSection() {
let section: React.ReactNode = ''
if (raidItems) {
section = raidItems && raidItems.length > 0 && (
<section className={styles['raids']}>
<h4>{updates(`labels.raids`)}</h4>
<div className={styles.items}>{raidItemElements()}</div>
</section>
)
}
return section
}
function awakenedItemElements() {
let elements: React.ReactNode[] = []
if (awakenedItems) {
elements = awakenedItems.map((id) => {
return <ChangelogUnit id={id} type="weapon" key={id} />
})
}
return elements
}
function awakenedItemSection() {
let section: React.ReactNode = ''
if (awakenedItems && awakenedItems.length > 0) {
section = (
<section className={styles['weapons']}>
<h4>{updates(`labels.awakened.weapons`)}</h4>
<div className={styles.items}>{awakenedItemElements()}</div>
</section>
)
}
return section
}
function raidItemElements() {
let elements: React.ReactNode[] = []
if (raidItems) {
elements = raidItems.map((id) => {
return <ChangelogUnit id={id} type="raid" key={id} />
})
}
return elements
}
return ( return (
<section <section
className={classNames({ className={classNames({
@ -212,15 +118,10 @@ const ContentUpdate = ({
<div className={styles.contents}> <div className={styles.contents}>
{newItemSection('character')} {newItemSection('character')}
{uncapItemSection('character')} {uncapItemSection('character')}
{transcendItemSection('character')}
{newItemSection('weapon')} {newItemSection('weapon')}
{uncapItemSection('weapon')} {uncapItemSection('weapon')}
{transcendItemSection('weapon')}
{newItemSection('summon')} {newItemSection('summon')}
{uncapItemSection('summon')} {uncapItemSection('summon')}
{transcendItemSection('summon')}
{awakenedItemSection()}
{newRaidSection()}
</div> </div>
{numNotes > 0 ? ( {numNotes > 0 ? (
<div className={styles.notes}> <div className={styles.notes}>

View file

@ -28,10 +28,6 @@
} }
} }
&.constrained.update {
max-width: 360px;
}
&.github:hover .left .icon svg { &.github:hover .left .icon svg {
fill: var(--text-primary); fill: var(--text-primary);
} }

View file

@ -1,4 +1,5 @@
import { ComponentProps } from 'react' import { ComponentProps } from 'react'
import Link from 'next/link'
import classNames from 'classnames' import classNames from 'classnames'
import ShareIcon from '~public/icons/Share.svg' import ShareIcon from '~public/icons/Share.svg'
@ -20,6 +21,7 @@ const LinkItem = ({ icon, title, link, className, ...props }: Props) => {
return ( return (
<div className={classes}> <div className={classes}>
<Link href={link}>
<a href={link} target="_blank" rel="noreferrer"> <a href={link} target="_blank" rel="noreferrer">
<div className={styles.left}> <div className={styles.left}>
<i className={styles.icon}>{icon}</i> <i className={styles.icon}>{icon}</i>
@ -27,6 +29,7 @@ const LinkItem = ({ icon, title, link, className, ...props }: Props) => {
</div> </div>
<ShareIcon className={styles.shareIcon} /> <ShareIcon className={styles.shareIcon} />
</a> </a>
</Link>
</div> </div>
) )
} }

View file

@ -1,8 +1,6 @@
'use client'
import React from 'react' import React from 'react'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import LinkItem from '~components/about/LinkItem' import LinkItem from '~components/about/LinkItem'
@ -13,8 +11,8 @@ import styles from './index.module.scss'
const ROADMAP_ITEMS = 6 const ROADMAP_ITEMS = 6
const RoadmapPage = () => { const RoadmapPage = () => {
const common = useTranslations('common') const { t: common } = useTranslation('common')
const about = useTranslations('about') const { t: about } = useTranslation('about')
const classes = classNames(styles.roadmap, 'PageContent') const classes = classNames(styles.roadmap, 'PageContent')

View file

@ -1,30 +1,123 @@
.updates { .updates {
.top { padding-bottom: $unit-12x;
.version {
display: flex;
flex-direction: column;
gap: $unit-2x;
&.content {
.header h3 {
color: var(--accent-yellow);
}
}
.contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
.features {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $unit-2x;
li {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit; gap: $unit;
h3 {
font-weight: $bold;
margin-top: $unit-half;
} }
.yearSelector { img {
aspect-ratio: 4 / 3;
background: var(--dialog-bg);
border-radius: $input-corner;
display: block;
width: 100%;
}
code {
background: var(--button-bg);
border-radius: 2px;
font-family: monospace;
font-weight: $bold;
letter-spacing: 0.02rem;
padding: 1px;
}
}
}
}
.header {
align-items: baseline;
display: flex; display: flex;
flex-direction: row; gap: $unit-half;
margin-bottom: $unit-2x;
h3 {
color: var(--accent-blue);
font-weight: $medium;
font-size: $font-large;
}
time {
color: var(--text-secondary);
font-size: $font-small;
font-weight: $medium;
}
}
.list {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
.Contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
&.Bare {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
section {
display: flex;
flex-direction: column;
gap: $unit-2x; gap: $unit-2x;
.yearButton { h2 {
background: none; margin: 0;
border: none; }
font-size: $font-medium;
font-weight: $bold;
font-variant-numeric: oldstyle-nums;
padding: 0;
&.active {
color: var(--accent-blue);
} }
&:hover { .Bugs {
color: var(--accent-blue); display: flex;
cursor: pointer; flex-direction: column;
list-style-type: disc;
gap: $unit-half;
padding-left: $unit-2x;
} }
} }
} }

View file

@ -1,67 +1,377 @@
import React, { useState } from 'react' import React from 'react'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import classNames from 'classnames' import classNames from 'classnames'
import ContentUpdate2022 from '../updates/ContentUpdate2022' import ContentUpdate from '~components/about/ContentUpdate'
import ContentUpdate2023 from '../updates/ContentUpdate2023'
import ContentUpdate2024 from '../updates/ContentUpdate2024'
import styles from './index.module.scss' import styles from './index.module.scss'
const UpdatesPage = () => { const UpdatesPage = () => {
const common = useTranslations('common') const { t: common } = useTranslation('common')
const updates = useTranslations('updates') const { t: updates } = useTranslation('updates')
const classes = classNames(styles.updates, 'PageContent') const classes = classNames(styles.updates, 'PageContent')
// Default to most recent year with content (2024) const versionUpdates = {
const [activeYear, setActiveYear] = useState(2024) '1.0.0': 5,
const getYearButtonClass = (year: number) => '1.0.1': 4,
classNames({ '1.1.0': {
[styles.yearButton]: true, updates: 10,
[styles.active]: activeYear === year, bugs: 4,
}) images: [
'remix',
// Render the component based on the active year 'unauth',
const renderContentUpdate = () => { 'transcendence',
switch (activeYear) { 'accessories',
case 2022: 'mastery',
return <ContentUpdate2022 /> 'mechanics',
case 2023: 'rare',
return <ContentUpdate2023 /> 'urls',
case 2024: 'nav',
return <ContentUpdate2024 /> 'toasts',
default: ],
return <div>{updates('noUpdates')}</div> },
'202302U2': {
updates: 1,
},
} }
function image(
alt: string,
url: string,
filename: string,
extension: string
) {
const fallback = `${url}/${filename}.${extension}`
let set = []
for (let i = 1; i < 3; i++) {
if (i === 1) set.push(fallback)
else set.push(`${url}/${filename}@${i}x.${extension} ${i}x`)
}
const sizes = set.join(', ')
return <img alt={alt} src={fallback} srcSet={sizes} />
} }
return ( return (
<div className={classes}> <div className={classes}>
<div className={styles.top}>
<h1>{common('about.segmented_control.updates')}</h1> <h1>{common('about.segmented_control.updates')}</h1>
<div className={styles.yearSelector}> <ContentUpdate
<button version="2023-06L"
className={getYearButtonClass(2024)} dateString="2023/06/29"
onClick={() => setActiveYear(2024)} event="events.legfest"
> newItems={{
2024 character: ['3040468000', '3040469000'],
</button> weapon: ['1040421900', '1040712600', '1040516000', '1030305700'],
<button }}
className={getYearButtonClass(2023)} />
onClick={() => setActiveYear(2023)} <ContentUpdate
> version="2023-06F"
2023 dateString="2023/06/19"
</button> event="events.flash"
<button newItems={{
className={getYearButtonClass(2022)} character: ['3040466000', '3040467000'],
onClick={() => setActiveYear(2022)} weapon: ['1040915300', '1040815700'],
> }}
2022 />
</button> <ContentUpdate
version="2023-06U1"
dateString="2023/06/07"
event="events.uncap"
uncappedItems={{
character: ['3040169000', '3040163000'],
}}
/>
<ContentUpdate
version="2023-05L"
dateString="2023/05/31"
event="events.legfest"
newItems={{
character: ['3040464000', '3040465000'],
weapon: ['1040116900', '1040218400', '1040712500', '1030804400'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-05F"
dateString="2023/05/20"
event="events.flash"
newItems={{
character: ['3040463000', '3040462000'],
weapon: ['1040421800', '1040024600'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-05U"
dateString="2023/05/18"
event="events.content"
newItems={{
weapon: ['1040712400'],
}}
uncappedItems={{
character: ['3040073000'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-04L"
dateString="2023/04/30"
event="events.legfest"
newItems={{
character: ['3040460000', '3040461000'],
weapon: ['1040815500', '1040815600', '1040421700', '1030208100'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-04U"
dateString="2023/04/01"
event="events.content"
newItems={{
character: ['3040457000'],
summon: ['2040419000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-03L"
dateString="2023/03/31"
event="events.legfest"
newItems={{
character: ['3040456000', '3040455000'],
weapon: ['1040316100', '1040617500'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-03U2"
dateString="2023/03/30"
event="events.content"
uncappedItems={{
character: ['3040164000', '3040160000'],
}}
newItems={{
weapon: [
'1040815100',
'1040815200',
'1040815300',
'1040815400',
'1040815000',
'1040024400',
'1030609400',
],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-03U"
dateString="2023/03/22"
event="events.content"
newItems={{
weapon: ['1040024300'],
}}
uncappedItems={{
weapon: [
'1040217600',
'1040312800',
'1040023200',
'1040217800',
'1040420800',
'1040213900',
'1040116200',
'1040216500',
'1040616700',
'1040420700',
'1040913000',
'1040419000',
],
summon: [
'2040398000',
'2040413000',
'2040401000',
'2040406000',
'2040418000',
'2040409000',
'2040056000',
],
}}
numNotes={2}
/>
<ContentUpdate
version="2023-03F"
dateString="2023/03/16"
event="events.flash"
newItems={{
character: ['3040451000', '3040452000', '3040453000', '3040454000'],
weapon: ['1040914600', '1040116800', '1040515900', '1040712300'],
}}
numNotes={7}
/>
<ContentUpdate
version="2023-02L"
dateString="2023/02/27"
event="events.legfest"
newItems={{
character: ['3040450000', '3040449000'],
weapon: ['1040421600', '1040617300', '1040712200'],
summon: ['2040418000'],
}}
/>
<ContentUpdate
version="2023-02F"
dateString="2023/02/14"
event="events.flash"
newItems={{
character: ['3040447000', '3040448000'],
weapon: ['1040617200', '1040421500'],
}}
/>
<ContentUpdate
version="2023-02-U3"
dateString="2023/02/12"
event="events.uncap"
uncappedItems={{
character: ['3040173000'],
weapon: ['1040606800', '1040606900', '1040607000', '1040509500'],
summon: ['2040288000'],
}}
/>
<ContentUpdate
version="2023-02-U2"
dateString="2023/02/06"
event="events.uncap"
newItems={{
weapon: ['1040016100'],
}}
numNotes={versionUpdates['202302U2'].updates}
uncappedItems={{
character: ['3040252000'],
weapon: ['1040617100', '1040016100'],
}}
/>
<section className={styles.version} data-version="1.1">
<div className={styles.header}>
<h3>1.1.0</h3>
<time>2023/02/06</time>
</div> </div>
<div className={styles.contents}>
<section>
<h2>{updates('labels.features')}</h2>
<ul className={styles.features}>
{[...Array(versionUpdates['1.1.0'].updates)].map((e, i) => (
<li key={`1.1.0-update-${i}`}>
{image(
updates(`versions.1.1.0.features.${i}.title`),
`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/updates`,
versionUpdates['1.1.0'].images[i],
'jpg'
)}
<h3>{updates(`versions.1.1.0.features.${i}.title`)}</h3>
<p>{updates(`versions.1.1.0.features.${i}.blurb`)}</p>
</li>
))}
</ul>
</section>
<section>
<h2>Bug fixes</h2>
<ul className={styles.bugs}>
{[...Array(versionUpdates['1.1.0'].bugs)].map((e, i) => (
<li key={`1.1.0-bugfix-${i}`}>
{updates(`versions.1.1.0.bugs.${i}`)}
</li>
))}
</ul>
</section>
</div> </div>
{renderContentUpdate()} </section>
<ContentUpdate
version="2023-02-U1"
dateString="2023/02/01"
event="events.uncap"
uncappedItems={{
character: ['3040136000', '3040219000'],
weapon: ['1040412800', '1040511300'],
summon: ['2040234000', '2040331000'],
}}
/>
<ContentUpdate
version="2023-01F"
dateString="2023/01/31"
event={'events.legfest'}
newItems={{
character: ['3040445000', '3040446000'],
weapon: ['1040116700', '1040421400', '1040316000', '1030208000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-01F"
dateString="2023/01/19"
event="events.flash"
newItems={{
character: ['3040444000', '3040443000'],
weapon: ['1040218300', '1040116600'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-01U"
dateString="2023/01/06"
event="events.uncap"
uncappedItems={{
character: ['3040196000'],
}}
numNotes={0}
/>
<section className={styles.version} data-version="1.0">
<div className={styles.header}>
<h3>1.0.1</h3>
<time>2023/01/08</time>
</div>
<ul className={styles.list}>
{[...Array(versionUpdates['1.0.1'])].map((e, i) => (
<li key={`1.0.1-update-${i}`}>
{updates(`versions.1.0.1.features.${i}`)}
</li>
))}
</ul>
</section>
<ContentUpdate
version="2022-12L"
dateString="2022/12/26"
event="events.legfest"
newItems={{
character: ['3040440000', '3040441000', '3040442000'],
weapon: ['1040315900', '1040914500', '1040218200'],
summon: ['2040417000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2022-12F2"
dateString="2022/12/26"
event="events.flash"
newItems={{
character: ['3040438000', '3040439000'],
weapon: ['1040024200', '1040116500'],
}}
numNotes={0}
/>
<section className={styles.version} data-version="1.0">
<div className={styles.header}>
<h3>1.0.0</h3>
<time>2022/12/26</time>
</div>
<ul className={styles.list}>
{[...Array(versionUpdates['1.0.0'])].map((e, i) => (
<li key={`1.0.0-update-${i}`}>
{updates(`versions.1.0.0.features.${i}`)}
</li>
))}
</ul>
</section>
</div> </div>
) )
} }

View file

@ -1,133 +0,0 @@
.updates {
padding-bottom: $unit-12x;
.version {
display: flex;
flex-direction: column;
&.content {
.header h3 {
color: var(--accent-yellow);
}
}
.bugs {
display: flex;
flex-direction: column;
list-style-type: disc;
gap: $unit-half;
padding-left: $unit-2x;
}
.contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
.foreword {
margin-top: $unit-4x;
p {
font-size: $font-regular;
line-height: 1.32;
margin-bottom: $unit-2x;
}
}
.features {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $unit-2x;
li {
display: flex;
flex-direction: column;
gap: $unit;
h3 {
font-weight: $bold;
margin-top: $unit-half;
}
img {
aspect-ratio: 4 / 3;
background: var(--dialog-bg);
border-radius: $input-corner;
display: block;
width: 100%;
}
code {
background: var(--button-bg);
border-radius: 2px;
font-family: monospace;
font-weight: $bold;
letter-spacing: 0.02rem;
padding: 1px;
}
}
}
}
.header {
align-items: baseline;
display: flex;
gap: $unit-half;
margin-bottom: $unit-2x;
h3 {
color: var(--accent-blue);
font-weight: $medium;
font-size: $font-large;
}
time {
color: var(--text-secondary);
font-size: $font-small;
font-weight: $medium;
}
}
.list {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
.Contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
&.Bare {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
section {
display: flex;
flex-direction: column;
gap: $unit-2x;
h2 {
margin: 0;
}
}
}
}
}

View file

@ -1,54 +0,0 @@
import React from 'react'
import { useTranslations } from 'next-intl'
import ContentUpdate from '~components/about/ContentUpdate'
import styles from './index.module.scss'
const ContentUpdate2022 = () => {
const updates = useTranslations('updates')
const versionUpdates = {
'1.0.0': 5,
}
return (
<>
<ContentUpdate
version="2022-12L"
dateString="2022/12/26"
event="events.legfest"
newItems={{
character: ['3040440000', '3040441000', '3040442000'],
weapon: ['1040315900', '1040914500', '1040218200'],
summon: ['2040417000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2022-12F2"
dateString="2022/12/26"
event="events.flash"
newItems={{
character: ['3040438000', '3040439000'],
weapon: ['1040024200', '1040116500'],
}}
numNotes={0}
/>
<section className={styles.version} data-version="1.0">
<div className={styles.header}>
<h3>1.0.0</h3>
<time>2022/12/26</time>
</div>
<ul className={styles.list}>
{[...Array(versionUpdates['1.0.0'])].map((e, i) => (
<li key={`1.0.0-update-${i}`}>
{updates(`versions.v1_0_0.features.${i}`)}
</li>
))}
</ul>
</section>
</>
)
}
export default ContentUpdate2022

View file

@ -1,133 +0,0 @@
.updates {
padding-bottom: $unit-12x;
.version {
display: flex;
flex-direction: column;
&.content {
.header h3 {
color: var(--accent-yellow);
}
}
.bugs {
display: flex;
flex-direction: column;
list-style-type: disc;
gap: $unit-half;
padding-left: $unit-2x;
}
.contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
.foreword {
margin-top: $unit-4x;
p {
font-size: $font-regular;
line-height: 1.32;
margin-bottom: $unit-2x;
}
}
.features {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $unit-2x;
li {
display: flex;
flex-direction: column;
gap: $unit;
h3 {
font-weight: $bold;
margin-top: $unit-half;
}
img {
aspect-ratio: 4 / 3;
background: var(--dialog-bg);
border-radius: $input-corner;
display: block;
width: 100%;
}
code {
background: var(--button-bg);
border-radius: 2px;
font-family: monospace;
font-weight: $bold;
letter-spacing: 0.02rem;
padding: 1px;
}
}
}
}
.header {
align-items: baseline;
display: flex;
gap: $unit-half;
margin-bottom: $unit-2x;
h3 {
color: var(--accent-blue);
font-weight: $medium;
font-size: $font-large;
}
time {
color: var(--text-secondary);
font-size: $font-small;
font-weight: $medium;
}
}
.list {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
.Contents {
display: flex;
flex-direction: column;
gap: $unit-4x;
&.Bare {
display: flex;
flex-direction: column;
color: var(--text-primary);
list-style-type: disc;
list-style-position: inside;
gap: $unit-half;
li {
font-size: 14px;
}
}
section {
display: flex;
flex-direction: column;
gap: $unit-2x;
h2 {
margin: 0;
}
}
}
}
}

View file

@ -1,683 +0,0 @@
import React from 'react'
import { useTranslations } from 'next-intl'
import ContentUpdate from '~components/about/ContentUpdate'
import LinkItem from '../../LinkItem'
import DiscordIcon from '~public/icons/discord.svg'
import styles from './index.module.scss'
const ContentUpdate2023 = () => {
const updates = useTranslations('updates')
const versionUpdates = {
'1.0.1': 4,
'1.1.0': {
updates: 10,
bugs: 4,
images: [
'remix',
'unauth',
'transcendence',
'accessories',
'mastery',
'mechanics',
'rare',
'urls',
'nav',
'toasts',
],
},
'1.2.0': {
updates: 10,
bugs: 0,
images: [
'party-peek',
'party-redesign',
'visibility',
'rich-text',
'mentions',
'include-exclude',
'raid-search',
'search-views',
'quick-summon',
'grand-awakening',
],
},
'202302U2': {
updates: 1,
},
'1.2.1': {
bugs: 5,
},
}
function image(
alt: string,
url: string,
filename: string,
extension: string
) {
const fallback = `${url}/${filename}.${extension}`
let set = []
for (let i = 1; i < 3; i++) {
if (i === 1) set.push(fallback)
else set.push(`${url}/${filename}@${i}x.${extension} ${i}x`)
}
const sizes = set.join(', ')
return <img alt={alt} src={fallback} srcSet={sizes} />
}
return (
<>
<ContentUpdate
version="2023-12L"
dateString="2023/12/31"
event="events.legfest"
newItems={{
weapon: ['1040119000', '1040618200', '1040317000'],
character: ['3040502000', '3040501000', '3040503000'],
summon: ['2040425000'],
}}
/>
<ContentUpdate
version="2023-12F2"
dateString="2023/12/28"
event="events.flash"
newItems={{
weapon: ['1040218900', '1040618100', '1040025500', '1030305900'],
character: ['3040499000', '3040500000'],
summon: ['2040427000'],
}}
/>
<ContentUpdate
version="2023-12U2"
dateString="2023/12/19"
event="events.content"
uncappedItems={{
weapon: [
'1040815100',
'1040815200',
'1040815300',
'1040815400',
'1040815000',
],
}}
numNotes={2}
/>
<ContentUpdate
version="2023-12F"
dateString="2023/12/17"
event="events.flash"
newItems={{
weapon: ['1040218800', '1040816200'],
character: ['3040498000', '3040497000'],
}}
uncappedItems={{
weapon: ['1040416500', '1040215000'],
}}
/>
<ContentUpdate
version="2023-12U"
dateString="2023/12/07"
event="events.content"
newItems={{
weapon: ['1040118000'],
}}
/>
<ContentUpdate
version="2023-11L"
dateString="2023/11/30"
event="events.legfest"
newItems={{
weapon: ['1040516700', '1040713100', '1040117900', '1030609500'],
character: ['3040496000', '3040495000'],
}}
/>
<ContentUpdate
version="2023-11F"
dateString="2023/11/17"
event="events.flash"
newItems={{
weapon: ['1040117800', '1040516600', '1040025300'],
character: ['3040492000', '3040493000', '3040494000'],
}}
/>
<ContentUpdate
version="2023-11U2"
dateString="2023/11/14"
event="events.uncap"
uncappedItems={{
character: ['3040212000'],
}}
/>
<ContentUpdate
version="2023-11U"
dateString="2023/11/09"
event="events.content"
newItems={{
weapon: [
'1040025200',
'1040316800',
'1040316900',
'1040025100',
'1040712900',
'1040713000',
'1040915900',
'1040617900',
'1040618000',
'1040117700',
'1040316600',
'1040316700',
'1040422300',
'1040816000',
'1040816100',
'1040916000',
'1040117500',
'1040117600',
],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-10L"
dateString="2023/10/31"
event="events.legfest"
newItems={{
weapon: ['1040915800', '1040117400', '1040915700', '1030804500'],
character: ['3040490000', '3040491000'],
summon: ['2040424000'],
}}
/>
<ContentUpdate
version="2023-10U"
dateString="2023/10/23"
event="events.content"
newItems={{
weapon: [
'1040422200',
'1040815900',
'1040316500',
'1040712800',
'1040516500',
'1040915600',
],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-10F"
dateString="2023/10/18"
event="events.flash"
newItems={{
weapon: ['1040516400', '1040422100', '1040316400'],
character: ['3040487000', '3040488000', '3040489000'],
}}
/>
<ContentUpdate
version="2023-10U"
dateString="2023/10/16"
event="events.uncap"
uncappedItems={{
character: ['3040109000', '3040168000', '3040162000'],
}}
/>
<ContentUpdate
version="2023-09L"
dateString="2023/09/30"
event="events.legfest"
newItems={{
weapon: ['1040915500', '1040617800', '1040117300', '1030406500'],
character: ['3040485000', '3040484000'],
}}
/>
<ContentUpdate
version="2023-09F"
dateString="2023/09/15"
event="events.flash"
newItems={{
weapon: ['1040117200', '1040024900'],
character: ['3040486000', '3040483000'],
}}
uncappedItems={{
character: ['3040064000'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-09U"
dateString="2023/09/07"
event="events.content"
newItems={{
weapon: ['1040117000', '1040516300'],
}}
numNotes={1}
/>
<section className={styles.version} data-version="1.2.1">
<div className={styles.header}>
<h3>1.2.1</h3>
<time>2023/09/01</time>
</div>
<h2>Bug fixes</h2>
<ul className={styles.bugs}>
{[...Array(versionUpdates['1.2.1'].bugs)].map((e, i) => (
<li key={`1.2.1-bugfix-${i}`}>
{updates(`versions.v1_2_1.bugs.${i}`)}
</li>
))}
</ul>
</section>
<ContentUpdate
version="2023-08L"
dateString="2023/08/31"
event="events.legfest"
newItems={{
character: ['3040481000', '3040482000'],
weapon: ['1040218700', '1040617700', '1040712700', '1030406400'],
}}
/>
<section className={styles.version} data-version="1.2">
<div className={styles.header}>
<h3>1.2.0</h3>
<time>2023/08/25</time>
</div>
<div className={styles.contents}>
<section>
<h2>{updates('labels.features')}</h2>
<ul className={styles.features}>
{[...Array(versionUpdates['1.2.0'].updates)].map((e, i) => (
<li key={`1.2.0-update-${i}`}>
{image(
updates(`versions.v1_2_0.features.${i}.title`),
`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/updates`,
versionUpdates['1.2.0'].images[i],
'jpg'
)}
<h3>{updates(`versions.v1_2_0.features.${i}.title`)}</h3>
<p>{updates(`versions.v1_2_0.features.${i}.blurb`)}</p>
</li>
))}
</ul>
<div className={styles.foreword}>
<h2>Developer notes</h2>
{updates('versions.v1_2_0.notes')
.split('\n')
.map((item, i) => (
<p key={`note-${i}`}>{item}</p>
))}
<LinkItem
className="discord constrained update"
title="granblue-tools"
link="https://discord.gg/qyZ5hGdPC8"
icon={<DiscordIcon />}
/>
</div>
</section>
{/* <section>
<h2>Bug fixes</h2>
<ul className={styles.bugs}>
{[...Array(versionUpdates['1.2.0'].bugs)].map((e, i) => (
<li key={`1.2.0-bugfix-${i}`}>
{updates(`versions.v1_2_0.bugs.${i}`)}
</li>
))}
</ul>
</section> */}
</div>
</section>
<ContentUpdate
version="2023-08U"
dateString="2023/08/22"
event="events.uncap"
uncappedItems={{
summon: ['2040185000', '2040225000', '2040205000', '2040261000'],
}}
/>
<ContentUpdate
version="2023-08F"
dateString="2023/08/16"
event="events.flash"
newItems={{
character: ['3040478000', '3040479000', '3040480000'],
weapon: ['1040915400', '1040024800', '1040422000'],
summon: ['2040423000'],
}}
uncappedItems={{
character: ['3040161000', '3040165000'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-08U"
dateString="2023/08/11"
event="events.content"
newItems={{
character: ['3040476000', '3040477000'],
weapon: ['1040117100'],
summon: ['2040422000', '2040421000'],
}}
/>
<ContentUpdate
version="2023-07L"
dateString="2023/07/31"
event="events.legfest"
newItems={{
character: ['3040472000', '3040474000', '3040475000', '3040473000'],
weapon: [
'1040815800',
'1040024700',
'1040516200',
'1040218600',
'1040617600',
'1030305800',
],
summon: ['2040420000'],
}}
/>
<ContentUpdate
version="2023-07F"
dateString="2023/07/15"
event="events.flash"
newItems={{
character: ['3040470000', '3040471000'],
weapon: ['1040316300', '1040516100'],
}}
/>
<ContentUpdate
version="2023-07U"
dateString="2023/07/08"
event="events.uncap"
newItems={{
weapon: ['1040218500'],
}}
uncappedItems={{
character: ['3040102000'],
}}
/>
<ContentUpdate
version="2023-06L"
dateString="2023/06/29"
event="events.legfest"
newItems={{
character: ['3040468000', '3040469000'],
weapon: ['1040421900', '1040712600', '1040516000', '1030305700'],
}}
/>
<ContentUpdate
version="2023-06F"
dateString="2023/06/19"
event="events.flash"
newItems={{
character: ['3040466000', '3040467000'],
weapon: ['1040915300', '1040815700'],
}}
/>
<ContentUpdate
version="2023-06U1"
dateString="2023/06/07"
event="events.uncap"
uncappedItems={{
character: ['3040169000', '3040163000'],
}}
/>
<ContentUpdate
version="2023-05L"
dateString="2023/05/31"
event="events.legfest"
newItems={{
character: ['3040464000', '3040465000'],
weapon: ['1040116900', '1040218400', '1040712500', '1030804400'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-05F"
dateString="2023/05/20"
event="events.flash"
newItems={{
character: ['3040463000', '3040462000'],
weapon: ['1040421800', '1040024600'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-05U"
dateString="2023/05/18"
event="events.content"
newItems={{
weapon: ['1040712400'],
}}
uncappedItems={{
character: ['3040073000'],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-04L"
dateString="2023/04/30"
event="events.legfest"
newItems={{
character: ['3040460000', '3040461000'],
weapon: ['1040815500', '1040815600', '1040421700', '1030208100'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-04U"
dateString="2023/04/01"
event="events.content"
newItems={{
character: ['3040457000'],
summon: ['2040419000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-03L"
dateString="2023/03/31"
event="events.legfest"
newItems={{
character: ['3040456000', '3040455000'],
weapon: ['1040316100', '1040617500'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-03U2"
dateString="2023/03/30"
event="events.content"
uncappedItems={{
character: ['3040164000', '3040160000'],
}}
newItems={{
weapon: [
'1040815100',
'1040815200',
'1040815300',
'1040815400',
'1040815000',
'1040024400',
'1030609400',
],
}}
numNotes={1}
/>
<ContentUpdate
version="2023-03U"
dateString="2023/03/22"
event="events.content"
newItems={{
weapon: ['1040024300'],
}}
uncappedItems={{
weapon: [
'1040217600',
'1040312800',
'1040023200',
'1040217800',
'1040420800',
'1040213900',
'1040116200',
'1040216500',
'1040616700',
'1040420700',
'1040913000',
'1040419000',
],
summon: [
'2040398000',
'2040413000',
'2040401000',
'2040406000',
'2040418000',
'2040409000',
'2040056000',
],
}}
numNotes={2}
/>
<ContentUpdate
version="2023-03F"
dateString="2023/03/16"
event="events.flash"
newItems={{
character: ['3040451000', '3040452000', '3040453000', '3040454000'],
weapon: ['1040914600', '1040116800', '1040515900', '1040712300'],
}}
numNotes={7}
/>
<ContentUpdate
version="2023-02L"
dateString="2023/02/27"
event="events.legfest"
newItems={{
character: ['3040450000', '3040449000'],
weapon: ['1040421600', '1040617300', '1040712200'],
summon: ['2040418000'],
}}
/>
<ContentUpdate
version="2023-02F"
dateString="2023/02/14"
event="events.flash"
newItems={{
character: ['3040447000', '3040448000'],
weapon: ['1040617200', '1040421500'],
}}
/>
<ContentUpdate
version="2023-02-U3"
dateString="2023/02/12"
event="events.uncap"
uncappedItems={{
character: ['3040173000'],
weapon: ['1040606800', '1040606900', '1040607000', '1040509500'],
summon: ['2040288000'],
}}
/>
<ContentUpdate
version="2023-02-U2"
dateString="2023/02/06"
event="events.uncap"
newItems={{
weapon: ['1040016100'],
}}
numNotes={versionUpdates['202302U2'].updates}
uncappedItems={{
character: ['3040252000'],
weapon: ['1040617100', '1040016100'],
}}
/>
<section className={styles.version} data-version="1.1">
<div className={styles.header}>
<h3>1.1.0</h3>
<time>2023/02/06</time>
</div>
<div className={styles.contents}>
<section>
<h2>{updates('labels.features')}</h2>
<ul className={styles.features}>
{[...Array(versionUpdates['1.1.0'].updates)].map((e, i) => (
<li key={`1.1.0-update-${i}`}>
{image(
updates(`versions.v1_1_0.features.${i}.title`),
`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/updates`,
versionUpdates['1.1.0'].images[i],
'jpg'
)}
<h3>{updates(`versions.v1_1_0.features.${i}.title`)}</h3>
<p>{updates(`versions.v1_1_0.features.${i}.blurb`)}</p>
</li>
))}
</ul>
</section>
<section>
<h2>Bug fixes</h2>
<ul className={styles.bugs}>
{[...Array(versionUpdates['1.1.0'].bugs)].map((e, i) => (
<li key={`1.1.0-bugfix-${i}`}>
{updates(`versions.v1_1_0.bugs.${i}`)}
</li>
))}
</ul>
</section>
</div>
</section>
<ContentUpdate
version="2023-02-U1"
dateString="2023/02/01"
event="events.uncap"
uncappedItems={{
character: ['3040136000', '3040219000'],
weapon: ['1040412800', '1040511300'],
summon: ['2040234000', '2040331000'],
}}
/>
<ContentUpdate
version="2023-01F"
dateString="2023/01/31"
event={'events.legfest'}
newItems={{
character: ['3040445000', '3040446000'],
weapon: ['1040116700', '1040421400', '1040316000', '1030208000'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-01F"
dateString="2023/01/19"
event="events.flash"
newItems={{
character: ['3040444000', '3040443000'],
weapon: ['1040218300', '1040116600'],
}}
numNotes={0}
/>
<ContentUpdate
version="2023-01U"
dateString="2023/01/06"
event="events.uncap"
uncappedItems={{
character: ['3040196000'],
}}
numNotes={0}
/>
<section className={styles.version} data-version="1.0">
<div className={styles.header}>
<h3>1.0.1</h3>
<time>2023/01/08</time>
</div>
<ul className={styles.list}>
{[...Array(versionUpdates['1.0.1'])].map((e, i) => (
<li key={`1.0.1-update-${i}`}>
{updates(`versions.v1_0_1.features.${i}`)}
</li>
))}
</ul>
</section>
</>
)
}
export default ContentUpdate2023

View file

@ -1,269 +0,0 @@
import React from 'react'
import { useTranslations } from 'next-intl'
import ContentUpdate from '~components/about/ContentUpdate'
const ContentUpdate2024 = () => {
const updates = useTranslations('updates')
return (
<>
<ContentUpdate
version="2024-05U1"
dateString="2024/05/02"
event="events.content"
newItems={{
weapon: [
'1040026100',
'1040317400',
'1040423100',
'1040119500',
'1040618800',
'1040916300',
],
}}
transcendedItems={{
summon: ['2040034000', '2040046000'],
}}
numNotes={4}
/>
<ContentUpdate
version="2024-04L"
dateString="2024/04/30"
event="events.legfest"
newItems={{
character: ['3040529000', '3040530000'],
weapon: ['1040219200', '1040119400', '1040618700', '1030109000'],
}}
/>
<ContentUpdate
version="2024-04U2"
dateString="2024/04/21"
event="events.content"
newItems={{
character: ['3040525000'],
}}
uncappedItems={{
weapon: ['1040313200'],
}}
/>
<ContentUpdate
version="2024-04F"
dateString="2024/04/15"
event="events.flash"
newItems={{
character: ['3040523000', '3040524000'],
weapon: ['1040119300', '1040423000'],
}}
/>
<ContentUpdate
version="2024-04U1"
dateString="2024/04/07"
event="events.content"
newItems={{
character: ['3040522000'],
weapon: ['1040618600'],
}}
/>
<ContentUpdate
version="2024-03L"
dateString="2024/03/31"
event="events.legfest"
newItems={{
character: ['3040520000', '3040521000'],
weapon: ['1040026000', '1040422900', '1040422800', '1030704700'],
}}
/>
<ContentUpdate
version="2024-03U3"
dateString="2024/03/25"
event="events.content"
transcendedItems={{
summon: ['2040020000', '2040047000'],
}}
newItems={{
weapon: [
'1040119200',
'1040516800',
'1040713400',
'1040219100',
'1040516900',
'1040916200',
],
}}
numNotes={3}
/>
<ContentUpdate
version="2024-03F"
dateString="2024/03/19"
event="events.flash"
newItems={{
weapon: ['1040317200', '1040422600', '1040422700'],
character: ['3040517000', '3040518000', '3040519000'],
}}
/>
<ContentUpdate
version="2024-03U2"
dateString="2024/03/15"
event="events.content"
newItems={{
weapon: ['1040713300'],
character: ['3040516000'],
}}
uncappedItems={{
weapon: ['1040614500'],
}}
/>
<ContentUpdate
version="2024-03U"
dateString="2024/03/10"
event="events.content"
transcendedItems={{
summon: [
'2040094000',
'2040100000',
'2040098000',
'2040084000',
'2040090000',
'2040080000',
],
}}
uncappedItems={{
weapon: [
'1040516200',
'1040915300',
'1040116500',
'1040815800',
'1040710900',
'1040024700',
'1040712600',
'1040116100',
'1040712300',
'1040806000',
'1040515900',
'1040616800',
],
}}
awakenedItems={[
'1040906400',
'1040708700',
'1040212700',
'1040910000',
'1040014300',
'1040207000',
]}
/>
<ContentUpdate
version="2024-02L"
dateString="2024/02/29"
event="events.legfest"
newItems={{
character: ['3040515000', '3040513000', '3040514000'],
weapon: [
'1040025900',
'1040618500',
'1040119100',
'1040025800',
'1030010200',
],
}}
/>
<ContentUpdate
version="2024-02U"
dateString="2024/02/20"
event="events.content"
newItems={{
weapon: ['1040618400'],
}}
raidItems={['dark-rapture-zero']}
numNotes={3}
/>
<ContentUpdate
version="2024-02F"
dateString="2024/02/14"
event="events.flash"
newItems={{
character: ['3040512000', '3040511000'],
weapon: ['1040713200', '1040816400'],
}}
/>
<ContentUpdate
version="2024-01U"
dateString="2024/02/06"
event="events.uncap"
uncappedItems={{
character: ['3040190000'],
}}
/>
<ContentUpdate
version="2024-01L"
dateString="2024/01/31"
event="events.legfest"
newItems={{
character: ['3040509000', '3040510000'],
weapon: ['1040025700', '1040422500', '1040317100', '1030406600'],
}}
numNotes={1}
/>
<ContentUpdate
version="2024-01U3"
dateString="2024/01/18"
event="events.content"
newItems={{
character: ['3040506000'],
}}
uncappedItems={{
character: ['3040313000'],
}}
/>
<ContentUpdate
version="2024-01F"
dateString="2024/01/15"
event="events.flash"
newItems={{
character: ['3040508000', '3040507000'],
weapon: ['1040422400', '1040219000'],
}}
transcendedItems={{
weapon: [
'1040212600',
'1040212500',
'1040310700',
'1040310600',
'1040415100',
'1040415000',
'1040809500',
'1040809400',
'1040911100',
'1040911000',
'1040017100',
'1040017000',
],
}}
/>
<ContentUpdate
version="2024-01U2"
dateString="2024/01/12"
event="events.content"
newItems={{
character: ['3040504000', '3040505000'],
weapon: ['1040618300'],
summon: ['2040426000'],
}}
/>
<ContentUpdate
version="2024-01U"
dateString="2024/01/05"
event="events.content"
newItems={{
weapon: ['1040025400', '1040816300'],
}}
uncappedItems={{
character: ['3040167000', '3040166000'],
}}
numNotes={2}
/>
</>
)
}
export default ContentUpdate2024

View file

@ -1,9 +1,7 @@
'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 { Dialog } from '~components/common/Dialog'
@ -20,7 +18,6 @@ import { accountState } from '~utils/accountState'
import { pictureData } from '~utils/pictureData' import { pictureData } from '~utils/pictureData'
import styles from './index.module.scss' import styles from './index.module.scss'
import SwitchTableField from '~components/common/SwitchTableField'
interface Props { interface Props {
open: boolean open: boolean
@ -30,19 +27,18 @@ 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)
@ -57,7 +53,6 @@ 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)
// Setup // Setup
const [pictureOpen, setPictureOpen] = useState(false) const [pictureOpen, setPictureOpen] = useState(false)
@ -140,7 +135,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 +145,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 +152,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()
}) })
} }
} }
@ -274,15 +265,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,8 +277,8 @@ 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}
> >
@ -311,7 +293,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
{genderField()} {genderField()}
{languageField()} {languageField()}
{themeField()} {themeField()}
{props.role === 9 && adminField()}
</div> </div>
<DialogFooter <DialogFooter

View file

@ -1,9 +1,7 @@
'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { setCookie } from 'cookies-next' import { setCookie } from 'cookies-next'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/router'
import { useTranslations } from 'next-intl' import { useTranslation } from 'react-i18next'
import axios, { AxiosError, AxiosResponse } from 'axios' import axios, { AxiosError, AxiosResponse } from 'axios'
import api from '~utils/api' import api from '~utils/api'
@ -19,7 +17,6 @@ import DialogFooter from '~components/common/DialogFooter'
import DialogContent from '~components/common/DialogContent' import DialogContent from '~components/common/DialogContent'
import styles from './index.module.scss' import styles from './index.module.scss'
import { userAgent } from 'next/server'
interface ErrorMap { interface ErrorMap {
[index: string]: string [index: string]: string
@ -37,7 +34,7 @@ interface Props {
const LoginModal = (props: Props) => { const LoginModal = (props: Props) => {
const router = useRouter() const router = useRouter()
const t = useTranslations('common') const { t } = useTranslation('common')
// Set up form states and error handling // Set up form states and error handling
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
@ -113,9 +110,9 @@ const LoginModal = (props: Props) => {
.login(body) .login(body)
.then((response) => { .then((response) => {
storeCookieInfo(response) storeCookieInfo(response)
return response.data.user.username return response.data.user.id
}) })
.then((username) => fetchUserInfo(username)) .then((id) => fetchUserInfo(id))
.then((infoResponse) => storeUserInfo(infoResponse)) .then((infoResponse) => storeUserInfo(infoResponse))
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@ -133,8 +130,8 @@ const LoginModal = (props: Props) => {
} }
} }
function fetchUserInfo(username: string) { function fetchUserInfo(id: string) {
return api.userInfo(username) return api.userInfo(id)
} }
function storeCookieInfo(response: AxiosResponse) { function storeCookieInfo(response: AxiosResponse) {
@ -143,7 +140,6 @@ const LoginModal = (props: Props) => {
const cookieObj: AccountCookie = { const cookieObj: AccountCookie = {
userId: resp.user.id, userId: resp.user.id,
username: resp.user.username, username: resp.user.username,
role: resp.user.role,
token: resp.access_token, token: resp.access_token,
} }
@ -173,7 +169,6 @@ const LoginModal = (props: Props) => {
language: user.language, language: user.language,
gender: user.gender, gender: user.gender,
theme: user.theme, theme: user.theme,
bahamut: false,
}, },
{ path: '/', expires: expiresAt } { path: '/', expires: expiresAt }
) )
@ -183,7 +178,6 @@ const LoginModal = (props: 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,
@ -191,7 +185,6 @@ const LoginModal = (props: Props) => {
gender: user.gender, gender: user.gender,
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
bahamut: false,
} }
console.log('Authorizing account...') console.log('Authorizing account...')
@ -225,7 +218,7 @@ const LoginModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}> <Dialog open={open} onOpenChange={openChange}>
<DialogContent <DialogContent
className="login" className="login"
footerRef={footerRef} footerref={footerRef}
onEscapeKeyDown={onEscapeKeyDown} onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus} onOpenAutoFocus={onOpenAutoFocus}
> >

View file

@ -1,9 +1,7 @@
'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { setCookie, getCookie } from 'cookies-next' import { 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 { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import api from '~utils/api' import api from '~utils/api'
@ -37,7 +35,7 @@ const emailRegex =
const SignupModal = (props: Props) => { const SignupModal = (props: Props) => {
const router = useRouter() const router = useRouter()
const t = useTranslations('common') const { t } = useTranslation('common')
// Set up form states and error handling // Set up form states and error handling
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
@ -72,16 +70,13 @@ const SignupModal = (props: Props) => {
function register(event: React.FormEvent) { function register(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
// In App Router, locale is typically handled via cookies or headers
const currentLocale = getCookie('NEXT_LOCALE') as string || 'en'
const body = { const body = {
user: { user: {
username: usernameInput.current?.value, username: usernameInput.current?.value,
email: emailInput.current?.value, email: emailInput.current?.value,
password: passwordInput.current?.value, password: passwordInput.current?.value,
password_confirmation: passwordConfirmationInput.current?.value, password_confirmation: passwordConfirmationInput.current?.value,
language: currentLocale, language: router.locale,
}, },
} }
@ -90,9 +85,9 @@ const SignupModal = (props: Props) => {
.create(body) .create(body)
.then((response) => { .then((response) => {
storeCookieInfo(response) storeCookieInfo(response)
return response.data.username return response.data.id
}) })
.then((username) => fetchUserInfo(username)) .then((id) => fetchUserInfo(id))
.then((infoResponse) => storeUserInfo(infoResponse)) .then((infoResponse) => storeUserInfo(infoResponse))
} }
@ -102,7 +97,6 @@ const SignupModal = (props: Props) => {
const cookieObj: AccountCookie = { const cookieObj: AccountCookie = {
userId: resp.id, userId: resp.id,
username: resp.username, username: resp.username,
role: resp.role,
token: resp.token, token: resp.token,
} }
@ -114,8 +108,8 @@ const SignupModal = (props: Props) => {
setHeaders() setHeaders()
} }
function fetchUserInfo(username: string) { function fetchUserInfo(id: string) {
return api.userInfo(username) return api.userInfo(id)
} }
function storeUserInfo(response: AxiosResponse) { function storeUserInfo(response: AxiosResponse) {
@ -136,7 +130,6 @@ const SignupModal = (props: Props) => {
language: user.language, language: user.language,
gender: user.gender, gender: user.gender,
theme: user.theme, theme: user.theme,
bahamut: false,
}, },
{ path: '/', expires: expiresAt } { path: '/', expires: expiresAt }
) )
@ -146,7 +139,6 @@ const SignupModal = (props: 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,
@ -154,7 +146,6 @@ const SignupModal = (props: Props) => {
gender: user.gender, gender: user.gender,
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
bahamut: false,
} }
console.log('Authorizing account...') console.log('Authorizing account...')
@ -307,7 +298,7 @@ const SignupModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}> <Dialog open={open} onOpenChange={openChange}>
<DialogContent <DialogContent
className="signup" className="signup"
footerRef={footerRef} footerref={footerRef}
onEscapeKeyDown={onEscapeKeyDown} onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus} onOpenAutoFocus={onOpenAutoFocus}
> >

View file

@ -1,9 +1,6 @@
'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/common/Dialog'
import DialogContent from '~components/common/DialogContent' import DialogContent from '~components/common/DialogContent'
@ -12,6 +9,7 @@ import Button from '~components/common/Button'
import Overlay from '~components/common/Overlay' import Overlay from '~components/common/Overlay'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
import { ElementMap } from '~utils/elements'
import styles from './index.module.scss' import styles from './index.module.scss'
@ -27,12 +25,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)
@ -52,21 +47,22 @@ const CharacterConflictModal = (props: Props) => {
else if (uncap > 2) suffix = '02' else if (uncap > 2) suffix = '02'
// Special casing for Lyria (and Young Cat eventually) // Special casing for Lyria (and Young Cat eventually)
if (character?.granblue_id === '3030182000') { if (character?.granblueId === '3030182000') {
let element = 1 let element: GranblueElement | undefined
if ( if (
appState.grid.weapons.mainWeapon && appState.grid.weapons.mainWeapon &&
appState.grid.weapons.mainWeapon.element appState.grid.weapons.mainWeapon.element
) { ) {
element = appState.grid.weapons.mainWeapon.element element = appState.grid.weapons.mainWeapon.element
} else if (appState.party.element != 0) { } else {
element = appState.party.element element = ElementMap.wind
} }
suffix = `${suffix}_0${element}` suffix = `${suffix}_0${element?.id}`
} }
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${character?.granblue_id}_${suffix}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${character?.granblueId}_${suffix}.jpg`
} }
function openChange(open: boolean) { function openChange(open: boolean) {
@ -83,15 +79,13 @@ const CharacterConflictModal = (props: Props) => {
<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={styles.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={styles.diagram}>
<ul> <ul>
@ -99,7 +93,7 @@ const CharacterConflictModal = (props: Props) => {
<li className={styles.character} key={`conflict-${i}`}> <li className={styles.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.uncapLevel)}
/> />
<span>{character.object.name[locale]}</span> <span>{character.object.name[locale]}</span>
</li> </li>

View file

@ -2,7 +2,7 @@
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'
@ -16,14 +16,17 @@ 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 * as CharacterTransformer from '~transformers/CharacterTransformer'
import * as GridCharacterTransformer from '~transformers/GridCharacterTransformer'
import styles from './index.module.scss' import styles from './index.module.scss'
import { use } from 'i18next'
// Props // Props
interface Props { interface Props {
new: boolean new: boolean
editable: boolean editable: boolean
characters?: GridCharacter[] characters?: GridArray<GridCharacter>
createParty: (details?: DetailsObject) => Promise<Party> createParty: (details?: DetailsObject) => Promise<Party>
pushHistory?: (path: string) => void pushHistory?: (path: string) => void
} }
@ -33,7 +36,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')
@ -46,7 +49,7 @@ const CharacterGrid = (props: Props) => {
const [errorAlertOpen, setErrorAlertOpen] = useState(false) const [errorAlertOpen, setErrorAlertOpen] = useState(false)
// Set up state for view management // Set up state for view management
const { party, grid } = useSnapshot(appState) const { party } = useSnapshot(appState)
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
// Set up state for conflict management // Set up state for conflict management
@ -76,19 +79,39 @@ const CharacterGrid = (props: Props) => {
}>({}) }>({})
useEffect(() => { useEffect(() => {
setJob(appState.party.job) console.log('loading chara grid')
setJobSkills(appState.party.jobSkills) }, [])
setJobAccessory(appState.party.accessory)
}, [appState]) useEffect(() => {
setJob(appState.party.protagonist.job)
setJobSkills(
appState.party.protagonist.skills
? appState.party.protagonist.skills
: {
0: undefined,
1: undefined,
2: undefined,
3: undefined,
}
)
setJobAccessory(
appState.party.protagonist.accessory
? appState.party.protagonist.accessory
: undefined
)
}, [])
// Initialize an array of current uncap values for each characters // Initialize an array of current uncap values for each characters
useEffect(() => { useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {} let initialPreviousUncapValues: { [key: number]: number } = {}
Object.values(appState.grid.characters).map((o) => { const values = appState.party.grid.characters
o ? (initialPreviousUncapValues[o.position] = o.uncap_level) : 0 ? appState.party.grid.characters
: {}
Object.values(values).map((o) => {
o ? (initialPreviousUncapValues[o.position] = o.uncapLevel) : 0
}) })
setPreviousUncapValues(initialPreviousUncapValues) setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.characters]) }, [appState.party.grid.characters])
// Methods: Adding an object from search // Methods: Adding an object from search
function receiveCharacterFromSearch( function receiveCharacterFromSearch(
@ -100,19 +123,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
@ -127,12 +144,13 @@ const CharacterGrid = (props: Props) => {
async function handleCharacterResponse(data: any) { async function handleCharacterResponse(data: any) {
if (data.hasOwnProperty('conflicts')) { if (data.hasOwnProperty('conflicts')) {
setIncoming(data.incoming) setIncoming(CharacterTransformer.toObject(data.incoming))
setConflicts(data.conflicts) setConflicts(
data.conflicts.map((c: any) => GridCharacterTransformer.toObject(c))
)
setPosition(data.position) setPosition(data.position)
setModalOpen(true) setModalOpen(true)
} else { } else {
console.log(data)
storeGridCharacter(data) storeGridCharacter(data)
} }
} }
@ -147,13 +165,18 @@ const CharacterGrid = (props: Props) => {
party_id: partyId, party_id: partyId,
character_id: character.id, character_id: character.id,
position: position, position: position,
uncap_level: characterUncapLevel(character), uncapLevel: characterUncapLevel(character),
}, },
}) })
} }
function storeGridCharacter(gridCharacter: GridCharacter) { function storeGridCharacter(data: any) {
appState.grid.characters[gridCharacter.position] = gridCharacter const gridCharacter = GridCharacterTransformer.toObject(data)
appState.party.grid.characters = {
...appState.party.grid.characters,
[gridCharacter.position]: gridCharacter,
}
} }
async function resolveConflict() { async function resolveConflict() {
@ -171,7 +194,11 @@ const CharacterGrid = (props: Props) => {
// Remove conflicting characters from state // Remove conflicting characters from state
conflicts.forEach( conflicts.forEach(
(c) => (appState.grid.characters[c.position] = undefined) (c) =>
(appState.party.grid.characters = {
...appState.party.grid.characters,
[c.position]: null,
})
) )
// Reset conflict // Reset conflict
@ -193,7 +220,10 @@ const CharacterGrid = (props: Props) => {
async function removeCharacter(id: string) { async function removeCharacter(id: string) {
try { try {
const response = await api.endpoints.grid_characters.destroy({ id: id }) const response = await api.endpoints.grid_characters.destroy({ id: id })
appState.grid.characters[response.data.position] = undefined appState.party.grid.characters = {
...appState.party.grid.characters,
[response.data.position]: null,
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@ -221,10 +251,10 @@ const CharacterGrid = (props: Props) => {
const team = response.data const team = response.data
setJob(team.job) setJob(team.job)
appState.party.job = team.job appState.party.protagonist.job = team.job
setJobSkills(team.job_skills) setJobSkills(team.job_skills)
appState.party.jobSkills = team.job_skills appState.party.protagonist.skills = team.job_skills
} }
} }
@ -251,7 +281,7 @@ const CharacterGrid = (props: Props) => {
// Update the current skills // Update the current skills
const newSkills = response.data.job_skills const newSkills = response.data.job_skills
setJobSkills(newSkills) setJobSkills(newSkills)
appState.party.jobSkills = newSkills appState.party.protagonist.skills = newSkills
}) })
.catch((error) => { .catch((error) => {
const data = error.response.data const data = error.response.data
@ -275,7 +305,7 @@ const CharacterGrid = (props: Props) => {
// Update the current skills // Update the current skills
const newSkills = response.data.job_skills const newSkills = response.data.job_skills
setJobSkills(newSkills) setJobSkills(newSkills)
appState.party.jobSkills = newSkills appState.party.protagonist.skills = newSkills
}) })
.catch((error) => { .catch((error) => {
const data = error.response.data const data = error.response.data
@ -298,7 +328,7 @@ const CharacterGrid = (props: Props) => {
) )
const team = response.data.party const team = response.data.party
setJobAccessory(team.accessory) setJobAccessory(team.accessory)
appState.party.accessory = team.accessory appState.party.protagonist.accessory = team.accessory
} }
} }
@ -385,10 +415,13 @@ const CharacterGrid = (props: Props) => {
position: number, position: number,
uncapLevel: number | undefined uncapLevel: number | undefined
) => { ) => {
const character = appState.grid.characters[position] const character = appState.party.grid.characters?.[position]
if (character && uncapLevel) { if (character && uncapLevel) {
character.uncap_level = uncapLevel character.uncapLevel = uncapLevel
appState.grid.characters[position] = character appState.party.grid.characters = {
...appState.party.grid.characters,
[position]: character,
}
} }
} }
@ -396,8 +429,8 @@ const CharacterGrid = (props: Props) => {
// Save the current value in case of an unexpected result // Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues } let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) { if (party.grid.characters && party.grid.characters[position]) {
newPreviousValues[position] = grid.characters[position]?.uncap_level newPreviousValues[position] = party.grid.characters[position]?.uncapLevel
setPreviousUncapValues(newPreviousValues) setPreviousUncapValues(newPreviousValues)
} }
} }
@ -414,7 +447,7 @@ const CharacterGrid = (props: Props) => {
const payload = { const payload = {
character: { character: {
uncap_level: stage > 0 ? 6 : 5, uncapLevel: stage > 0 ? 6 : 5,
transcendence_step: stage, transcendence_step: stage,
}, },
} }
@ -481,10 +514,13 @@ const CharacterGrid = (props: Props) => {
position: number, position: number,
stage: number | undefined stage: number | undefined
) => { ) => {
const character = appState.grid.characters[position] const character = appState.party.grid.characters?.[position]
if (character && stage !== undefined) { if (character && stage !== undefined) {
character.transcendence_step = stage character.transcendenceStep = stage
appState.grid.characters[position] = character appState.party.grid.characters = {
...appState.party.grid.characters,
[position]: character,
}
} }
} }
@ -492,8 +528,8 @@ const CharacterGrid = (props: Props) => {
// Save the current value in case of an unexpected result // Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues } let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) { if (party.grid.characters && party.grid.characters[position]) {
newPreviousValues[position] = grid.characters[position]?.uncap_level newPreviousValues[position] = party.grid.characters[position]?.uncapLevel
setPreviousTranscendenceStages(newPreviousValues) setPreviousTranscendenceStages(newPreviousValues)
} }
} }
@ -508,9 +544,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')}
/> />
@ -549,7 +583,9 @@ const CharacterGrid = (props: Props) => {
return ( return (
<li key={`grid_unit_${i}`}> <li key={`grid_unit_${i}`}>
<CharacterUnit <CharacterUnit
gridCharacter={grid.characters[i]} gridCharacter={
party.grid.characters ? party.grid.characters[i] : null
}
editable={props.editable} editable={props.editable}
position={i} position={i}
updateObject={receiveCharacterFromSearch} updateObject={receiveCharacterFromSearch}

View file

@ -1,18 +1,14 @@
'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 Button from '~components/common/Button'
import { import {
Hovercard, Hovercard,
HovercardContent, HovercardContent,
HovercardTrigger, HovercardTrigger,
} from '~components/common/Hovercard' } from '~components/common/Hovercard'
import Button from '~components/common/Button' import HovercardHeader from '~components/HovercardHeader'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
import UncapIndicator from '~components/uncap/UncapIndicator'
import { import {
overMastery, overMastery,
@ -22,7 +18,6 @@ import {
import { ExtendedMastery } from '~types' import { ExtendedMastery } from '~types'
import styles from './index.module.scss' import styles from './index.module.scss'
import HovercardHeader from '~components/HovercardHeader'
interface Props { interface Props {
gridCharacter: GridCharacter gridCharacter: GridCharacter
@ -32,15 +27,11 @@ interface Props {
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 tintElement = props.gridCharacter.object.element.slug
const tintElement = Element[props.gridCharacter.object.element]
function goTo() { function goTo() {
const urlSafeName = props.gridCharacter.object.name.en.replaceAll(' ', '_') const urlSafeName = props.gridCharacter.object.name.en.replaceAll(' ', '_')
@ -71,11 +62,7 @@ const CharacterHovercard = (props: Props) => {
} }
const overMasterySection = () => { const overMasterySection = () => {
if ( if (props.gridCharacter && props.gridCharacter.mastery.overMastery) {
props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.over_mastery.length > 0
) {
return ( return (
<section className={styles.mastery}> <section className={styles.mastery}>
<h5 className={tintElement}> <h5 className={tintElement}>
@ -83,13 +70,13 @@ const CharacterHovercard = (props: Props) => {
</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.mastery.overMastery[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,9 +92,8 @@ const CharacterHovercard = (props: Props) => {
const aetherialMasterySection = () => { const aetherialMasterySection = () => {
if ( if (
props.gridCharacter && props.gridCharacter &&
props.gridCharacter.over_mastery && props.gridCharacter.mastery.aetherialMastery &&
props.gridCharacter.aetherial_mastery && props.gridCharacter.mastery.aetherialMastery.modifier > 0
props.gridCharacter.aetherial_mastery?.modifier > 0
) { ) {
return ( return (
<section className={styles.mastery}> <section className={styles.mastery}>
@ -117,7 +103,7 @@ const CharacterHovercard = (props: Props) => {
<ul> <ul>
{masteryElement( {masteryElement(
aetherialMastery, aetherialMastery,
props.gridCharacter.aetherial_mastery props.gridCharacter.mastery.aetherialMastery
)} )}
</ul> </ul>
</section> </section>
@ -126,7 +112,7 @@ const CharacterHovercard = (props: Props) => {
} }
const permanentMasterySection = () => { const permanentMasterySection = () => {
if (props.gridCharacter && props.gridCharacter.perpetuity) { if (props.gridCharacter && props.gridCharacter.mastery.perpetuity) {
return ( return (
<section className={styles.mastery}> <section className={styles.mastery}>
<h5 className={tintElement}> <h5 className={tintElement}>
@ -146,8 +132,9 @@ const CharacterHovercard = (props: Props) => {
} }
const awakeningSection = () => { const awakeningSection = () => {
if (props.gridCharacter.awakening) { const gridAwakening = props.gridCharacter.mastery.awakening
const gridAwakening = props.gridCharacter.awakening
if (gridAwakening) {
return ( return (
<section className={styles.awakening}> <section className={styles.awakening}>
<h5 className={tintElement}> <h5 className={tintElement}>

View file

@ -1,10 +1,7 @@
'use client'
// Core dependencies // Core dependencies
import React, { PropsWithChildren, useEffect, useState } from 'react' import React, { PropsWithChildren, 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 isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
// UI dependencies // UI dependencies
@ -25,6 +22,13 @@ const emptyExtendedMastery: ExtendedMastery = {
strength: 0, strength: 0,
} }
const emptyRingset: CharacterOverMastery = {
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
}
const MAX_AWAKENING_LEVEL = 9 const MAX_AWAKENING_LEVEL = 9
// Styles and icons // Styles and icons
@ -47,13 +51,6 @@ interface Props {
updateCharacter: (object: GridCharacterObject) => Promise<any> updateCharacter: (object: GridCharacterObject) => Promise<any>
} }
const AWAKENING_MAP: { [key: string]: string } = {
'character-balanced': 'b1847c82-ece0-4d7a-8af1-c7868d90f34a',
'character-atk': '6e233877-8cda-4c8f-a091-3db6f68749e2',
'character-def': 'c95441de-f949-4a62-b02b-101aa2e0a638',
'character-multi': 'e36b0573-79c3-4dd2-9524-c95def4bbb1a',
}
const CharacterModal = ({ const CharacterModal = ({
gridCharacter, gridCharacter,
children, children,
@ -63,12 +60,9 @@ const CharacterModal = ({
}: PropsWithChildren<Props>) => { }: PropsWithChildren<Props>) => {
// Router and localization // Router and localization
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'
const t = useTranslations('common') const { t } = useTranslation('common')
// State: Component // State: Component
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -77,13 +71,16 @@ const CharacterModal = ({
// State: Data // State: Data
const [perpetuity, setPerpetuity] = useState(false) const [perpetuity, setPerpetuity] = useState(false)
const [rings, setRings] = useState<CharacterOverMastery>([]) const [rings, setRings] = useState<CharacterOverMastery>({
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
})
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery) const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
const [awakening, setAwakening] = useState<Awakening>() const [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1) const [awakeningLevel, setAwakeningLevel] = useState(1)
const [transcendenceStep, setTranscendenceStep] = useState( const [transcendenceStep, setTranscendenceStep] = useState(0)
gridCharacter.transcendence_step
)
// Refs // Refs
const headerRef = React.createRef<HTMLDivElement>() const headerRef = React.createRef<HTMLDivElement>()
@ -95,43 +92,66 @@ const CharacterModal = ({
}, [modalOpen]) }, [modalOpen])
useEffect(() => { useEffect(() => {
if (gridCharacter.aetherial_mastery) { console.log('Setting up grid character')
console.log(gridCharacter)
if (gridCharacter.mastery.overMastery) {
setRings(gridCharacter.mastery.overMastery)
} else {
setRings(emptyRingset)
}
if (gridCharacter.mastery.aetherialMastery) {
setEarring({ setEarring({
modifier: gridCharacter.aetherial_mastery.modifier, modifier: gridCharacter.mastery.aetherialMastery.modifier,
strength: gridCharacter.aetherial_mastery.strength, strength: gridCharacter.mastery.aetherialMastery.strength,
}) })
} }
if (gridCharacter.awakening) { setAwakening(gridCharacter.mastery.awakening.type)
setAwakening(gridCharacter.awakening.type) setAwakeningLevel(
setAwakeningLevel(gridCharacter.awakening.level) gridCharacter.mastery.awakening.level
} ? gridCharacter.mastery.awakening.level
setPerpetuity(gridCharacter.perpetuity) : 1
)
setPerpetuity(gridCharacter.mastery.perpetuity)
}, [gridCharacter]) }, [gridCharacter])
// Prepare the GridWeaponObject to send to the server // Prepare the GridWeaponObject to send to the server
function prepareObject(): GridCharacterObject { function prepareObject() {
return { let object: GridCharacterObject = {
character: { character: {
rings: rings, // your local rings array 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: { earring: {
modifier: earring.modifier, modifier: earring.modifier,
strength: strength: earring.strength,
earring.modifier && earring.modifier > 0 ? earring.strength : 0,
}, },
// Only include awakening if one is set.
...(awakening
? {
awakening: {
id: awakening.id,
level: awakeningLevel,
},
}
: {}),
transcendence_step: transcendenceStep, transcendence_step: transcendenceStep,
perpetuity: perpetuity, perpetuity: perpetuity,
}, },
} }
if (awakening) {
object.character.awakening_id = awakening.id
object.character.awakening_level = awakeningLevel
}
return object
} }
// Methods: Modification checking // Methods: Modification checking
@ -144,25 +164,18 @@ const CharacterModal = ({
rings || rings ||
aetherialMastery || aetherialMastery ||
awakening || awakening ||
gridCharacter.perpetuity !== perpetuity gridCharacter.mastery.perpetuity !== perpetuity
) )
} }
function ringsChanged() { function ringsChanged() {
// Create an empty ExtendedMastery object
const emptyRingset: CharacterOverMastery = [
{ ...emptyExtendedMastery, modifier: 1 },
{ ...emptyExtendedMastery, modifier: 2 },
emptyExtendedMastery,
emptyExtendedMastery,
]
// Check if the current ringset is empty on the current GridCharacter and our local state // Check if the current ringset is empty on the current GridCharacter and our local state
const isEmptyRingset = const isEmptyRingset =
gridCharacter.over_mastery === undefined && isEqual(emptyRingset, rings) gridCharacter.mastery.overMastery === undefined &&
isEqual(emptyRingset, rings)
// Check if the ringset in local state is different from the one on the current GridCharacter // Check if the ringset in local state is different from the one on the current GridCharacter
const ringsChanged = !isEqual(gridCharacter.over_mastery, rings) const ringsChanged = !isEqual(gridCharacter.mastery.overMastery, rings)
// Return true if the ringset has been modified and is not empty // Return true if the ringset has been modified and is not empty
return ringsChanged && !isEmptyRingset return ringsChanged && !isEmptyRingset
@ -177,12 +190,12 @@ const CharacterModal = ({
// Check if the current earring is empty on the current GridCharacter and our local state // Check if the current earring is empty on the current GridCharacter and our local state
const isEmptyRingset = const isEmptyRingset =
gridCharacter.aetherial_mastery === undefined && gridCharacter.mastery.aetherialMastery === undefined &&
isEqual(emptyAetherialMastery, earring) isEqual(emptyAetherialMastery, earring)
// Check if the earring in local state is different from the one on the current GridCharacter // Check if the earring in local state is different from the one on the current GridCharacter
const aetherialMasteryChanged = !isEqual( const aetherialMasteryChanged = !isEqual(
gridCharacter.aetherial_mastery, gridCharacter.mastery.aetherialMastery,
earring earring
) )
@ -193,8 +206,8 @@ const CharacterModal = ({
function awakeningChanged() { function awakeningChanged() {
// Check if the awakening in local state is different from the one on the current GridCharacter // Check if the awakening in local state is different from the one on the current GridCharacter
const awakeningChanged = const awakeningChanged =
!isEqual(gridCharacter.awakening?.type, awakening) || !isEqual(gridCharacter.mastery.awakening.type, awakening) ||
gridCharacter.awakening?.level !== awakeningLevel gridCharacter.mastery.awakening.level !== awakeningLevel
// Return true if the awakening has been modified and is not empty // Return true if the awakening has been modified and is not empty
return awakeningChanged return awakeningChanged
@ -225,26 +238,8 @@ const CharacterModal = ({
}) })
} }
function receiveAwakeningValues(slug: string, level: number) { function receiveAwakeningValues(id: string, level: number) {
const mappedId = AWAKENING_MAP[slug] || null setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
const existingAwakening = gridCharacter.object.awakenings.find(
(a) => a.slug === slug
)
if (existingAwakening && mappedId) {
setAwakening({
...existingAwakening,
id: mappedId,
})
} else {
setAwakening({
id: mappedId || '',
slug,
name: { en: '', jp: '' },
order: 0,
})
}
setAwakeningLevel(level) setAwakeningLevel(level)
} }
@ -266,17 +261,21 @@ const CharacterModal = ({
function close() { function close() {
setEarring({ setEarring({
modifier: gridCharacter.aetherial_mastery modifier: gridCharacter.mastery.aetherialMastery
? gridCharacter.aetherial_mastery.modifier ? gridCharacter.mastery.aetherialMastery.modifier
: 0, : 0,
strength: gridCharacter.aetherial_mastery strength: gridCharacter.mastery.aetherialMastery
? gridCharacter.aetherial_mastery.strength ? gridCharacter.mastery.aetherialMastery.strength
: 0, : 0,
}) })
setRings(gridCharacter.over_mastery || emptyExtendedMastery) setRings(gridCharacter.mastery.overMastery || emptyExtendedMastery)
setAwakening(gridCharacter.awakening.type) setAwakening(gridCharacter.mastery.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level) setAwakeningLevel(
gridCharacter.mastery.awakening.level
? gridCharacter.mastery.awakening.level
: 1
)
setAlertOpen(false) setAlertOpen(false)
setOpen(false) setOpen(false)
@ -287,18 +286,21 @@ const CharacterModal = ({
const confirmationAlert = ( const confirmationAlert = (
<Alert <Alert
message={ message={
<> <span>
{t.rich('alert.unsaved_changes.object', { <Trans i18nKey="alerts.unsaved_changes.object">
objectName: gridCharacter.object.name[locale], You will lose all changes to{' '}
strong: (chunks) => <strong>{chunks}</strong>, <strong>{{ objectName: gridCharacter.object.name[locale] }}</strong>{' '}
br: () => <br /> if you continue.
})} <br />
</> <br />
Are you sure you want to continue without saving?
</Trans>
</span>
} }
open={alertOpen} open={alertOpen}
primaryActionText={t('alert.unsaved_changes.buttons.confirm')} primaryActionText="Close"
primaryAction={close} primaryAction={close}
cancelActionText={t('alert.unsaved_changes.buttons.cancel')} cancelActionText="Nevermind"
cancelAction={() => setAlertOpen(false)} cancelAction={() => setAlertOpen(false)}
/> />
) )
@ -320,13 +322,13 @@ const CharacterModal = ({
object="earring" object="earring"
dataSet={elementalizeAetherialMastery(gridCharacter)} dataSet={elementalizeAetherialMastery(gridCharacter)}
selectValue={ selectValue={
gridCharacter.over_mastery && gridCharacter.aetherial_mastery gridCharacter.mastery.aetherialMastery
? gridCharacter.aetherial_mastery?.modifier ? gridCharacter.mastery.aetherialMastery.modifier
: 0 : 0
} }
inputValue={ inputValue={
gridCharacter.over_mastery && gridCharacter.aetherial_mastery gridCharacter.mastery.aetherialMastery
? gridCharacter.aetherial_mastery?.strength ? gridCharacter.mastery.aetherialMastery.strength
: 0 : 0
} }
sendValidity={receiveValidity} sendValidity={receiveValidity}
@ -340,8 +342,8 @@ const CharacterModal = ({
<h3>{t('modals.characters.subtitles.awakening')}</h3> <h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelectWithInput <AwakeningSelectWithInput
dataSet={gridCharacter.object.awakenings} dataSet={gridCharacter.object.awakenings}
awakening={gridCharacter.awakening.type} awakening={gridCharacter.mastery.awakening.type}
level={gridCharacter.awakening.level} level={gridCharacter.mastery.awakening.level}
defaultAwakening={ defaultAwakening={
gridCharacter.object.awakenings.find( gridCharacter.object.awakenings.find(
(a) => a.slug === 'character-balanced' (a) => a.slug === 'character-balanced'
@ -369,8 +371,8 @@ const CharacterModal = ({
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent
className="character" className="character"
headerRef={headerRef} headerref={headerRef}
footerRef={footerRef} footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}} onEscapeKeyDown={() => {}}
> >
@ -379,7 +381,7 @@ const CharacterModal = ({
title={gridCharacter.object.name[locale]} title={gridCharacter.object.name[locale]}
subtitle={t('modals.characters.title')} subtitle={t('modals.characters.title')}
image={{ image={{
src: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${gridCharacter.object.granblue_id}_01.jpg`, src: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${gridCharacter.object.granblueId}_01.jpg`,
alt: gridCharacter.object.name[locale], alt: gridCharacter.object.name[locale],
}} }}
/> />

View file

@ -1,8 +1,5 @@
'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 UncapIndicator from '~components/uncap/UncapIndicator' import UncapIndicator from '~components/uncap/UncapIndicator'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon' import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
@ -18,19 +15,16 @@ const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const CharacterResult = (props: Props) => { const CharacterResult = (props: Props) => {
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'
const character = props.data const character = props.data
const characterUrl = () => { const characterUrl = () => {
let url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_01.jpg` let url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblueId}_01.jpg`
if (character.granblue_id === '3030182000') { if (character.granblueId === '3030182000') {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_01_01.jpg` url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblueId}_01_01.jpg`
} }
return url return url
@ -45,12 +39,11 @@ const CharacterResult = (props: Props) => {
type="character" type="character"
flb={character.uncap.flb} flb={character.uncap.flb}
ulb={character.uncap.ulb} ulb={character.uncap.ulb}
transcendence={character.uncap.ulb}
transcendenceStage={5}
special={character.special} special={character.special}
transcendenceStage={character.uncap.ulb ? 5 : 0}
/> />
<div className={styles.tags}> <div className={styles.tags}>
<WeaponLabelIcon labelType={Element[character.element]} /> <WeaponLabelIcon labelType={character.element.slug} />
</div> </div>
</div> </div>
</li> </li>

View file

@ -1,5 +1,5 @@
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 SearchFilter from '~components/search/SearchFilter'
@ -19,7 +19,7 @@ interface Props {
} }
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)

View file

@ -1,10 +1,7 @@
'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 { 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 cloneDeep from 'lodash.clonedeep'
@ -24,6 +21,8 @@ import UncapIndicator from '~components/uncap/UncapIndicator'
import api from '~utils/api' import api from '~utils/api'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
import { ElementMap } from '~utils/elements'
import * as GridCharacterTransformer from '~transformers/GridCharacterTransformer'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
import SettingsIcon from '~public/icons/Settings.svg' import SettingsIcon from '~public/icons/Settings.svg'
@ -39,7 +38,7 @@ import type {
import styles from './index.module.scss' import styles from './index.module.scss'
interface Props { interface Props {
gridCharacter?: GridCharacter gridCharacter: GridCharacter | null
position: number position: number
editable: boolean editable: boolean
removeCharacter: (id: string) => void removeCharacter: (id: string) => void
@ -58,13 +57,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)
@ -115,7 +111,7 @@ const CharacterUnit = ({
function handlePerpetuityClick() { function handlePerpetuityClick() {
if (gridCharacter) { if (gridCharacter) {
let object: PerpetuityObject = { let object: PerpetuityObject = {
character: { perpetuity: !gridCharacter.perpetuity }, character: { perpetuity: !gridCharacter.mastery.perpetuity },
} }
updateCharacter(object) updateCharacter(object)
@ -150,21 +146,23 @@ 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 = GridCharacterTransformer.toObject(
response.data
)
let character = cloneDeep(gridCharacter) let character = cloneDeep(gridCharacter)
if (character.over_mastery) { if (character.mastery.overMastery) {
const overMastery: CharacterOverMastery = [ const overMastery: CharacterOverMastery = {
gridCharacter.over_mastery[0], 1: gridCharacter.mastery.overMastery[0],
gridCharacter.over_mastery[1], 2: gridCharacter.mastery.overMastery[1],
gridCharacter.over_mastery[2], 3: gridCharacter.mastery.overMastery[2],
gridCharacter.over_mastery[3], 4: gridCharacter.mastery.overMastery[3],
]
character.over_mastery = overMastery
} }
appState.grid.characters[gridCharacter.position] = character character.mastery.overMastery = overMastery
}
appState.party.grid.characters[gridCharacter.position] = character
} }
function processError(error: any) { function processError(error: any) {
@ -193,23 +191,28 @@ const CharacterUnit = ({
// Change the image based on the uncap level // Change the image based on the uncap level
let suffix = '01' let suffix = '01'
if (gridCharacter.transcendence_step > 0) suffix = '04' if (
else if (gridCharacter.uncap_level >= 5) suffix = '03' gridCharacter.transcendenceStep &&
else if (gridCharacter.uncap_level > 2) suffix = '02' gridCharacter.transcendenceStep > 0
)
suffix = '04'
else if (gridCharacter.uncapLevel >= 5) suffix = '03'
else if (gridCharacter.uncapLevel > 2) suffix = '02'
// Special casing for Lyria (and Young Cat eventually) // Special casing for Lyria (and Young Cat eventually)
if (gridCharacter.object.granblue_id === '3030182000') { if (gridCharacter.object.granblueId === '3030182000') {
let element = 1 let element: GranblueElement | undefined
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) { if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
element = grid.weapons.mainWeapon.element element = grid.weapons.mainWeapon.element
} else if (party.element != 0) { } else {
element = party.element element = ElementMap.wind
} }
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}/character-main/${character.granblueId}_${suffix}.jpg`
} }
setImageUrl(imgSrc) setImageUrl(imgSrc)
@ -268,12 +271,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>
</>
} }
/> />
) )
@ -299,7 +301,7 @@ const CharacterUnit = ({
if (gridCharacter) { if (gridCharacter) {
const classes = classNames({ const classes = classNames({
[styles.perpetuity]: true, [styles.perpetuity]: true,
[styles.empty]: !gridCharacter.perpetuity, [styles.empty]: !gridCharacter.mastery.perpetuity,
}) })
return <i className={classes} onClick={handlePerpetuityClick} /> return <i className={classes} onClick={handlePerpetuityClick} />
@ -355,8 +357,8 @@ const CharacterUnit = ({
type="character" type="character"
flb={character.uncap.flb || false} flb={character.uncap.flb || false}
ulb={character.uncap.ulb || false} ulb={character.uncap.ulb || false}
uncapLevel={gridCharacter.uncap_level} uncapLevel={gridCharacter.uncapLevel}
transcendenceStage={gridCharacter.transcendence_step} transcendenceStage={gridCharacter.transcendenceStep}
position={gridCharacter.position} position={gridCharacter.position}
editable={editable} editable={editable}
updateUncap={passUncapData} updateUncap={passUncapData}

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