Compare commits

..

24 commits

Author SHA1 Message Date
849cea035b Force peer dependency issue 2023-07-05 21:41:33 -07:00
9de4fcca83 Fix build errors 2023-07-05 21:12:34 -07:00
bca541a857 Delete getLocale.tsx
We didn't actually use this
2023-07-05 21:09:21 -07:00
7ee1c3e857 Send locale to api with search query 2023-07-05 21:08:52 -07:00
5a12cf2fc1 Keep description reactive
This shouldn't work? The snapshot should be the reactive one? I don't fucking know
2023-07-05 20:40:41 -07:00
6cb607ed7a Handle numbers and value=0 better 2023-07-05 20:40:01 -07:00
9650c34957 Add support for displaying jobs in MentionList 2023-07-05 20:39:35 -07:00
98c072655d 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
2023-07-05 20:39:11 -07:00
b32caf8790 Remove min-width on tokens 2023-07-05 18:57:35 -07:00
b641091695 Implements Editor in edit team and team footer
This implements the Editor component in EditPartyModal and PartyFooter. In PartyFooter, it is read-only.
2023-07-05 18:57:27 -07:00
d950d3a935 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.
2023-07-05 18:56:32 -07:00
b1c8fb1a76 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.
2023-07-05 18:53:15 -07:00
52af8995e6 Update components that use character images 2023-07-05 18:51:02 -07:00
ddfab4b2c0 Update .gitignore 2023-07-05 18:49:49 -07:00
ce6e8c6471 Add tiptap and associated packages 2023-07-05 18:49:44 -07:00
a76729bbc6 Add no results string 2023-07-05 18:49:34 -07:00
eaf6da97b8 Update README.md
We renamed the folders for character images from `chara-` to `character-`
2023-07-05 18:49:25 -07:00
00f117c118 Remove fixed height on html tag for now 2023-07-05 18:49:08 -07:00
0fb674b150 Add new variables and themes 2023-07-05 18:48:56 -07:00
97ea666561 Add new api endpoint for searching all resources 2023-07-05 18:48:42 -07:00
b3b3c5c960 Update GranblueElement with slug 2023-07-05 18:48:32 -07:00
ed01ea6955 Clean description with DOMPurify 2023-07-04 16:56:54 -07:00
277a248ba1 Disabled Youtube code for now 2023-07-04 16:56:06 -07:00
63846089a4 Preliminary work around making an Element type 2023-07-04 12:04:43 -07:00
368 changed files with 6906 additions and 19111 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/raids*
public/images/gacha*
public/images/previews*
public/image/profiles*
# Typescript v1 declaration files
typings/
@ -88,7 +86,3 @@ typings/
# DS_Store
.DS_Store
*.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,
"i18n-ally.localesPaths": ["public/locales"],
"i18n-ally.keystyle": "nested"
"git.ignoreLimitWarning": true
}

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,7 +1,6 @@
'use client'
import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import * as ToggleGroup from '@radix-ui/react-toggle-group'
@ -13,10 +12,12 @@ interface Props {
}
const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
// Localization
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
// Router and localization
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
const [element, setElement] = useState(currentElement)

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { useTranslation } from 'next-i18next'
import Button from '~components/common/Button'
import { ResponseStatus } from '~types'
@ -13,7 +13,7 @@ interface Props {
const ErrorSection = ({ status }: Props) => {
// Import translations
const t = useTranslations('common')
const { t } = useTranslation('common')
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,272 @@
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 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
grid: GridWeapon[]
user?: User
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(() => {
const newWeapons = Array(numWeapons)
const gridWeapons = Array(numWeapons)
let foundMainhand = false
for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1) {
setMainhand(value.object)
foundMainhand = true
} else if (!value.mainhand && value.position != null) {
newWeapons[value.position] = value.object
gridWeapons[value.position] = value
}
}
if (!foundMainhand) {
setMainhand(undefined)
}
setWeapons(newWeapons)
setGrid(gridWeapons)
}, [props.grid])
function navigate() {
props.onClick(props.shortcode)
}
function generateMainhandImage() {
let url = ''
if (mainhand) {
const weapon = Object.values(props.grid).find(
(w) => w && w.object.id === mainhand.id
)
if (mainhand.element == 0 && weapon && weapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}_${weapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}.jpg`
}
}
return mainhand && props.grid[0] ? (
<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 == 0 && gridWeapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.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 {
display: flex;
flex-direction: row;

View file

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

View file

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

View file

@ -1,11 +1,9 @@
'use client'
import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import UncapIndicator from '~components/uncap/UncapIndicator'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
import styles from './index.module.scss'
import classNames from 'classnames'
interface Props {
gridObject: GridCharacter | GridSummon | GridWeapon
@ -29,7 +27,9 @@ const Proficiency = [
]
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 = () => {
if (type === 'character') {
@ -76,7 +76,7 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
) {
suffix = '_02'
} else if (
gridSummon.object.uncap.transcendence &&
gridSummon.object.uncap.xlb &&
gridSummon.transcendence_step > 0
) {
suffix = '_03'
@ -107,61 +107,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 (
<header className={styles.root}>
<div className={styles.title}>
@ -172,9 +117,21 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
</div>
</div>
<div className={styles.subInfo}>
{proficiency()}
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} />
{'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
className="hovercard"
type={type}
ulb={object.uncap.ulb || false}
flb={object.uncap.flb || false}

View file

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

View file

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

View file

@ -1,18 +1,17 @@
'use client'
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react'
import { useTranslations } from 'next-intl'
import { getCookie } from 'cookies-next'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import { SuggestionProps } from '@tiptap/suggestion'
import classNames from 'classnames'
import styles from './index.module.scss'
type Props = Pick<SuggestionProps, 'items' | 'command' | 'query'>
type Props = Pick<SuggestionProps, 'items' | 'command'>
export type MentionRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
@ -35,9 +34,10 @@ interface MentionProps extends SuggestionProps {
export const MentionList = forwardRef<MentionRef, Props>(
({ 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)
@ -113,9 +113,7 @@ export const MentionList = forwardRef<MentionRef, Props>(
))
) : (
<div className={styles.noResult}>
{props.query.length < 3
? t('search.errors.type')
: t('search.errors.no_results_generic')}
{t('search.errors.no_results_generic')}
</div>
)}
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,123 @@
.updates {
.top {
padding-bottom: $unit-12x;
.version {
display: flex;
flex-direction: column;
gap: $unit;
}
.yearSelector {
display: flex;
flex-direction: row;
gap: $unit-2x;
.yearButton {
background: none;
border: none;
font-size: $font-medium;
font-weight: $bold;
font-variant-numeric: oldstyle-nums;
padding: 0;
&.content {
.header h3 {
color: var(--accent-yellow);
}
}
&.active {
.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;
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;
}
&:hover {
color: var(--accent-blue);
cursor: pointer;
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;
}
}
.Bugs {
display: flex;
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 { useTranslations } from 'next-intl'
import React from 'react'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import ContentUpdate2022 from '../updates/ContentUpdate2022'
import ContentUpdate2023 from '../updates/ContentUpdate2023'
import ContentUpdate2024 from '../updates/ContentUpdate2024'
import ContentUpdate from '~components/about/ContentUpdate'
import styles from './index.module.scss'
const UpdatesPage = () => {
const common = useTranslations('common')
const updates = useTranslations('updates')
const { t: common } = useTranslation('common')
const { t: updates } = useTranslation('updates')
const classes = classNames(styles.updates, 'PageContent')
// Default to most recent year with content (2024)
const [activeYear, setActiveYear] = useState(2024)
const getYearButtonClass = (year: number) =>
classNames({
[styles.yearButton]: true,
[styles.active]: activeYear === year,
})
const versionUpdates = {
'1.0.0': 5,
'1.0.1': 4,
'1.1.0': {
updates: 10,
bugs: 4,
images: [
'remix',
'unauth',
'transcendence',
'accessories',
'mastery',
'mechanics',
'rare',
'urls',
'nav',
'toasts',
],
},
'202302U2': {
updates: 1,
},
}
// Render the component based on the active year
const renderContentUpdate = () => {
switch (activeYear) {
case 2022:
return <ContentUpdate2022 />
case 2023:
return <ContentUpdate2023 />
case 2024:
return <ContentUpdate2024 />
default:
return <div>{updates('noUpdates')}</div>
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 (
<div className={classes}>
<div className={styles.top}>
<h1>{common('about.segmented_control.updates')}</h1>
<div className={styles.yearSelector}>
<button
className={getYearButtonClass(2024)}
onClick={() => setActiveYear(2024)}
>
2024
</button>
<button
className={getYearButtonClass(2023)}
onClick={() => setActiveYear(2023)}
>
2023
</button>
<button
className={getYearButtonClass(2022)}
onClick={() => setActiveYear(2022)}
>
2022
</button>
<h1>{common('about.segmented_control.updates')}</h1>
<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>
{renderContentUpdate()}
<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>
</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>
)
}

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 { getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { useTheme } from 'next-themes'
import { Dialog } from '~components/common/Dialog'
@ -20,7 +18,6 @@ import { accountState } from '~utils/accountState'
import { pictureData } from '~utils/pictureData'
import styles from './index.module.scss'
import SwitchTableField from '~components/common/SwitchTableField'
interface Props {
open: boolean
@ -30,19 +27,18 @@ interface Props {
language?: string
theme?: string
private?: boolean
role?: number
bahamutMode?: boolean
onOpenChange?: (open: boolean) => void
}
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
function AccountModal(props: Props, forwardedRef) {
// Localization
const t = useTranslations('common')
const { t } = useTranslation('common')
const router = useRouter()
// In App Router, locale is handled via cookies
const currentLocale = getCookie('NEXT_LOCALE') as string || 'en'
const locale = ['en', 'ja'].includes(currentLocale) ? currentLocale : 'en'
const locale =
router.locale && ['en', 'ja'].includes(router.locale)
? router.locale
: 'en'
// useEffect only runs on the client, so now we can safely show the UI
const [mounted, setMounted] = useState(false)
@ -57,7 +53,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
const [language, setLanguage] = useState(props.language || '')
const [gender, setGender] = useState(props.gender || 0)
const [theme, setTheme] = useState(props.theme || 'system')
const [bahamutMode, setBahamutMode] = useState(props.bahamutMode || false)
// Setup
const [pictureOpen, setPictureOpen] = useState(false)
@ -140,7 +135,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
gender: user.gender,
language: user.language,
theme: user.theme,
bahamut: bahamutMode,
}
const expiresAt = new Date()
@ -151,7 +145,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
id: user.id,
username: user.username,
granblueId: '',
role: user.role,
avatar: {
picture: user.avatar.picture,
element: user.avatar.element,
@ -159,13 +152,11 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
language: user.language,
theme: user.theme,
gender: user.gender,
bahamut: bahamutMode,
}
setOpen(false)
if (props.onOpenChange) props.onOpenChange(false)
changeLanguage(router, user.language)
if (props.bahamutMode != bahamutMode) router.refresh()
})
}
}
@ -274,15 +265,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
</SelectTableField>
)
const adminField = () => (
<SwitchTableField
name="admin"
label={t('modals.settings.labels.admin')}
value={props.bahamutMode}
onValueChange={(value: boolean) => setBahamutMode(value)}
/>
)
useEffect(() => {
setMounted(true)
}, [])
@ -295,8 +277,8 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="Account"
headerRef={headerRef}
footerRef={footerRef}
headerref={headerRef}
footerref={footerRef}
onOpenAutoFocus={(event: Event) => {}}
onEscapeKeyDown={onEscapeKeyDown}
>
@ -311,7 +293,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
{genderField()}
{languageField()}
{themeField()}
{props.role === 9 && adminField()}
</div>
<DialogFooter

View file

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

View file

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

View file

@ -1,9 +1,6 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
import { Dialog } from '~components/common/Dialog'
import DialogContent from '~components/common/DialogContent'
@ -27,12 +24,9 @@ interface Props {
const CharacterConflictModal = (props: Props) => {
// Localization
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const t = useTranslations('common')
const routerLocale = getCookie('NEXT_LOCALE')
const { t } = useTranslation('common')
const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// States
const [open, setOpen] = useState(false)
@ -83,15 +77,13 @@ const CharacterConflictModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="conflict"
footerRef={footerRef}
footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={close}
>
<div className={styles.content}>
<p>
{t.rich('modals.conflict.character', {
strong: (chunks) => <strong>{chunks}</strong>
})}
<Trans i18nKey="modals.conflict.character"></Trans>
</p>
<div className={styles.diagram}>
<ul>

View file

@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio'
import { useTranslations } from 'next-intl'
import { useTranslation } from 'next-i18next'
import { AxiosError, AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
@ -33,7 +33,7 @@ const CharacterGrid = (props: Props) => {
const numCharacters: number = 5
// Localization
const t = useTranslations('common')
const { t } = useTranslation('common')
// Cookies
const cookie = getCookie('account')
@ -100,19 +100,13 @@ const CharacterGrid = (props: Props) => {
if (!party.id) {
props.createParty().then((team) => {
saveCharacter(team.id, character, position)
.then((response) => {
const data = response.data['grid_character']
storeGridCharacter(data)
})
.then((response) => storeGridCharacter(response.data))
.catch((error) => console.error(error))
})
} else {
if (props.editable)
saveCharacter(party.id, character, position)
.then((response) => {
const data = response.data['grid_character']
handleCharacterResponse(data)
})
.then((response) => handleCharacterResponse(response.data))
.catch((error) => {
const axiosError = error as AxiosError
const response = axiosError.response
@ -132,7 +126,6 @@ const CharacterGrid = (props: Props) => {
setPosition(data.position)
setModalOpen(true)
} else {
console.log(data)
storeGridCharacter(data)
}
}
@ -508,9 +501,7 @@ const CharacterGrid = (props: Props) => {
<Alert
open={errorAlertOpen}
title={axiosError ? `${axiosError.status}` : 'Error'}
message={axiosError?.statusText && axiosError.statusText !== 'undefined'
? t(`errors.${axiosError.statusText.toLowerCase()}`)
: t('errors.internal_server_error.description')}
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
cancelAction={() => setErrorAlertOpen(false)}
cancelActionText={t('buttons.confirm')}
/>

View file

@ -1,9 +1,6 @@
'use client'
import React from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import {
Hovercard,
@ -32,12 +29,9 @@ interface Props {
const CharacterHovercard = (props: Props) => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const t = useTranslations('common')
const routerLocale = getCookie('NEXT_LOCALE')
const { t } = useTranslation('common')
const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const tintElement = Element[props.gridCharacter.object.element]
@ -71,11 +65,7 @@ const CharacterHovercard = (props: Props) => {
}
const overMasterySection = () => {
if (
props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.over_mastery.length > 0
) {
if (props.gridCharacter && props.gridCharacter.over_mastery) {
return (
<section className={styles.mastery}>
<h5 className={tintElement}>
@ -83,13 +73,13 @@ const CharacterHovercard = (props: Props) => {
</h5>
<ul>
{[...Array(4)].map((e, i) => {
const ringIndex = i + 1
const ringStat: ExtendedMastery =
props.gridCharacter.over_mastery[i]
if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
if (i === 0 || i === 1) {
if (ringIndex === 1 || ringIndex === 2) {
return masteryElement(overMastery.a, ringStat)
} else if (i === 2) {
} else if (ringIndex === 3) {
return masteryElement(overMastery.b, ringStat)
} else {
return masteryElement(overMastery.c, ringStat)
@ -105,9 +95,8 @@ const CharacterHovercard = (props: Props) => {
const aetherialMasterySection = () => {
if (
props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.aetherial_mastery &&
props.gridCharacter.aetherial_mastery?.modifier > 0
props.gridCharacter.aetherial_mastery.modifier > 0
) {
return (
<section className={styles.mastery}>
@ -146,8 +135,9 @@ const CharacterHovercard = (props: Props) => {
}
const awakeningSection = () => {
if (props.gridCharacter.awakening) {
const gridAwakening = props.gridCharacter.awakening
const gridAwakening = props.gridCharacter.awakening
if (gridAwakening) {
return (
<section className={styles.awakening}>
<h5 className={tintElement}>

View file

@ -1,10 +1,7 @@
'use client'
// Core dependencies
import React, { PropsWithChildren, useEffect, useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
import isEqual from 'lodash/isEqual'
// UI dependencies
@ -47,13 +44,6 @@ interface Props {
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 = ({
gridCharacter,
children,
@ -63,12 +53,9 @@ const CharacterModal = ({
}: PropsWithChildren<Props>) => {
// Router and localization
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const routerLocale = getCookie('NEXT_LOCALE')
const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
const t = useTranslations('common')
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common')
// State: Component
const [open, setOpen] = useState(false)
@ -77,13 +64,16 @@ const CharacterModal = ({
// State: Data
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 [awakening, setAwakening] = useState<Awakening>()
const [awakeningLevel, setAwakeningLevel] = useState(1)
const [transcendenceStep, setTranscendenceStep] = useState(
gridCharacter.transcendence_step
)
const [transcendenceStep, setTranscendenceStep] = useState(0)
// Refs
const headerRef = React.createRef<HTMLDivElement>()
@ -102,36 +92,46 @@ const CharacterModal = ({
})
}
if (gridCharacter.awakening) {
setAwakening(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level)
}
setAwakening(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level)
setPerpetuity(gridCharacter.perpetuity)
}, [gridCharacter])
// Prepare the GridWeaponObject to send to the server
function prepareObject(): GridCharacterObject {
return {
function prepareObject() {
let object: GridCharacterObject = {
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: {
modifier: earring.modifier,
strength:
earring.modifier && earring.modifier > 0 ? earring.strength : 0,
strength: earring.strength,
},
// Only include awakening if one is set.
...(awakening
? {
awakening: {
id: awakening.id,
level: awakeningLevel,
},
}
: {}),
transcendence_step: transcendenceStep,
perpetuity: perpetuity,
},
}
if (awakening) {
object.character.awakening_id = awakening.id
object.character.awakening_level = awakeningLevel
}
return object
}
// Methods: Modification checking
@ -150,12 +150,12 @@ const CharacterModal = ({
function ringsChanged() {
// Create an empty ExtendedMastery object
const emptyRingset: CharacterOverMastery = [
{ ...emptyExtendedMastery, modifier: 1 },
{ ...emptyExtendedMastery, modifier: 2 },
emptyExtendedMastery,
emptyExtendedMastery,
]
const emptyRingset: CharacterOverMastery = {
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
}
// Check if the current ringset is empty on the current GridCharacter and our local state
const isEmptyRingset =
@ -193,8 +193,8 @@ const CharacterModal = ({
function awakeningChanged() {
// Check if the awakening in local state is different from the one on the current GridCharacter
const awakeningChanged =
!isEqual(gridCharacter.awakening?.type, awakening) ||
gridCharacter.awakening?.level !== awakeningLevel
!isEqual(gridCharacter.awakening.type, awakening) ||
gridCharacter.awakening.level !== awakeningLevel
// Return true if the awakening has been modified and is not empty
return awakeningChanged
@ -225,26 +225,8 @@ const CharacterModal = ({
})
}
function receiveAwakeningValues(slug: string, level: number) {
const mappedId = AWAKENING_MAP[slug] || null
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,
})
}
function receiveAwakeningValues(id: string, level: number) {
setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id))
setAwakeningLevel(level)
}
@ -287,18 +269,21 @@ const CharacterModal = ({
const confirmationAlert = (
<Alert
message={
<>
{t.rich('alert.unsaved_changes.object', {
objectName: gridCharacter.object.name[locale],
strong: (chunks) => <strong>{chunks}</strong>,
br: () => <br />
})}
</>
<span>
<Trans i18nKey="alerts.unsaved_changes.object">
You will lose all changes to{' '}
<strong>{{ objectName: gridCharacter.object.name[locale] }}</strong>{' '}
if you continue.
<br />
<br />
Are you sure you want to continue without saving?
</Trans>
</span>
}
open={alertOpen}
primaryActionText={t('alert.unsaved_changes.buttons.confirm')}
primaryActionText="Close"
primaryAction={close}
cancelActionText={t('alert.unsaved_changes.buttons.cancel')}
cancelActionText="Nevermind"
cancelAction={() => setAlertOpen(false)}
/>
)
@ -320,13 +305,13 @@ const CharacterModal = ({
object="earring"
dataSet={elementalizeAetherialMastery(gridCharacter)}
selectValue={
gridCharacter.over_mastery && gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery?.modifier
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.modifier
: 0
}
inputValue={
gridCharacter.over_mastery && gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery?.strength
gridCharacter.aetherial_mastery
? gridCharacter.aetherial_mastery.strength
: 0
}
sendValidity={receiveValidity}
@ -369,8 +354,8 @@ const CharacterModal = ({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="character"
headerRef={headerRef}
footerRef={footerRef}
headerref={headerRef}
footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>

View file

@ -1,8 +1,5 @@
'use client'
import React from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import UncapIndicator from '~components/uncap/UncapIndicator'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
@ -18,11 +15,8 @@ const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const CharacterResult = (props: Props) => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const routerLocale = getCookie('NEXT_LOCALE')
const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const character = props.data
@ -45,8 +39,6 @@ const CharacterResult = (props: Props) => {
type="character"
flb={character.uncap.flb}
ulb={character.uncap.ulb}
transcendence={character.uncap.ulb}
transcendenceStage={5}
special={character.special}
/>
<div className={styles.tags}>

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslation } from 'next-i18next'
import cloneDeep from 'lodash.clonedeep'
import SearchFilter from '~components/search/SearchFilter'
@ -19,7 +19,7 @@ interface Props {
}
const CharacterSearchFilterBar = (props: Props) => {
const t = useTranslations('common')
const { t } = useTranslation('common')
const [rarityMenu, setRarityMenu] = useState(false)
const [elementMenu, setElementMenu] = useState(false)

View file

@ -1,10 +1,7 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslations } from 'next-intl'
import { Trans, useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import classNames from 'classnames'
import cloneDeep from 'lodash.clonedeep'
@ -58,13 +55,10 @@ const CharacterUnit = ({
updateTranscendence,
}: Props) => {
// Translations and locale
const t = useTranslations('common')
const { t } = useTranslation('common')
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const routerLocale = getCookie('NEXT_LOCALE')
const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// State snapshot
const { party, grid } = useSnapshot(appState)
@ -154,12 +148,12 @@ const CharacterUnit = ({
let character = cloneDeep(gridCharacter)
if (character.over_mastery) {
const overMastery: CharacterOverMastery = [
gridCharacter.over_mastery[0],
gridCharacter.over_mastery[1],
gridCharacter.over_mastery[2],
gridCharacter.over_mastery[3],
]
const overMastery: CharacterOverMastery = {
1: gridCharacter.over_mastery[0],
2: gridCharacter.over_mastery[1],
3: gridCharacter.over_mastery[2],
4: gridCharacter.over_mastery[3],
}
character.over_mastery = overMastery
}
@ -268,12 +262,11 @@ const CharacterUnit = ({
cancelAction={() => setAlertOpen(false)}
cancelActionText={t('buttons.cancel')}
message={
<>
{t.rich('modals.characters.messages.remove', {
character: gridCharacter?.object.name[locale] || '',
strong: (chunks) => <strong>{chunks}</strong>
})}
</>
<Trans i18nKey="modals.characters.messages.remove">
Are you sure you want to remove{' '}
<strong>{{ character: gridCharacter?.object.name[locale] }}</strong>{' '}
from your team?
</Trans>
}
/>
)

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