Compare commits
117 commits
13-weapon-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b95bc6ba | |||
| 1f8de7ee30 | |||
| e4b7f0c356 | |||
| 02676fd7d4 | |||
| d2bf37a40e | |||
| 778a1c70bd | |||
| 73395efee8 | |||
| fa23c13db1 | |||
| 3d67622353 | |||
| b1472fd35d | |||
| 426645813e | |||
| 0be7be8612 | |||
| 156f4222d7 | |||
| 0e7aeed5d6 | |||
| a02a6c70aa | |||
| eff96e5a37 | |||
| 2628d1745b | |||
| 0dc03d44f3 | |||
| 4a30dbbf9f | |||
| 4bc211c240 | |||
| 2160a57a20 | |||
| 4a38168593 | |||
| 2eaaf1baae | |||
| fc616aab01 | |||
| 4dc2279d68 | |||
| ecbfd3ae7f | |||
|
|
de8e3b322c | ||
|
|
3cac1d03cc | ||
|
|
c43e36e525 | ||
| cfa78dccc3 | |||
| 5824b3ccea | |||
| eaef607dc3 | |||
| a3815d2866 | |||
| 31745b17de | |||
| 0f03ca5e27 | |||
| 3ee2a1ac47 | |||
| 5a5457f10d | |||
| 74077a0501 | |||
| 3b6cc5ba65 | |||
| acf9773f38 | |||
| cac2613e9e | |||
| bc3f716f8c | |||
| 9ad2b64e2d | |||
| 06c7192f25 | |||
| e93343018f | |||
| b665d005d5 | |||
| 02fe61df88 | |||
| c58957f98e | |||
| 8f9f7d7a07 | |||
| cb2efb07a5 | |||
| 3347d47eeb | |||
| 5449297a48 | |||
| 348277de9b | |||
| 57197c99e8 | |||
| 6135b5bed4 | |||
| 7dd5d6988a | |||
| ce3001132f | |||
| 27079a5a72 | |||
| 9a9e6a12f3 | |||
| c715dc2593 | |||
| ab4b563754 | |||
| 9bb5e721ff | |||
| b50ea1fa31 | |||
| 14ad468737 | |||
| b92b2fad1d | |||
| d8136d90c3 | |||
| 3ef77cec0c | |||
| 9e6c9a2108 | |||
| aabd7de207 | |||
| 62b957034f | |||
| 8877f3cfeb | |||
| 24d871e04a | |||
| 58087d9f5b | |||
| 4c7732d3cb | |||
| 2d1af335c3 | |||
| 74b41230e7 | |||
| bc8b4c200c | |||
| 737300c80a | |||
| 78202a49df | |||
| 10cb78c11f | |||
| 6dd2579e6e | |||
| a4e4328329 | |||
| 99c7eb73c1 | |||
| 51eb937e0a | |||
| e9ead2c7b3 | |||
| 67b7e3eb73 | |||
| e37495072d | |||
| 736ab4d175 | |||
| 29c9a700c1 | |||
| e5e946aee1 | |||
| d0b1b7fde2 | |||
| 19c852c13b | |||
| f7f723b3f4 | |||
| 65bc7100c4 | |||
| a19e2055b9 | |||
| 209f6b733f | |||
| 9f87d712b9 | |||
| 1806269877 | |||
| 4c949d9206 | |||
| 702566e2ed | |||
| e2effa0d66 | |||
| a820e5ad5f | |||
| 9c3c36e81b | |||
| 8cbdb1838d | |||
| 9ecba12421 | |||
| 9ed3a89a72 | |||
| e146453b31 | |||
| 2f572dc71c | |||
| b94ff33d04 | |||
| 686729ff9c | |||
| c08204dd1b | |||
| b8ae43ddaf | |||
| 363148599a | |||
| d2cb881640 | |||
| 1c2a1b6bb4 | |||
| 968ae5c41e | |||
| 7b54791bb3 |
595 changed files with 50590 additions and 20857 deletions
5
.aidigestignore
Normal file
5
.aidigestignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
public/images
|
||||||
|
public/labels
|
||||||
|
public/profiles
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
*.log
|
||||||
5
.env.local
Normal file
5
.env.local
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NEXT_PUBLIC_SIERO_API_URL=http://127.0.0.1:3000/api/v1
|
||||||
|
NEXT_PUBLIC_SIERO_OAUTH_URL=http://127.0.0.1:3000/oauth
|
||||||
|
NEXT_INTL_CONFIG_PATH=i18n/request.ts
|
||||||
|
DEBUG_API_URL=1
|
||||||
|
DEBUG_API_BODY=1
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"rules": {
|
"rules": {
|
||||||
// Other rules
|
|
||||||
"@next/next/no-img-element": "off"
|
"@next/next/no-img-element": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -49,13 +49,18 @@ dist/
|
||||||
# Instructions will be provided to download these from the game
|
# Instructions will be provided to download these from the game
|
||||||
public/images/weapon*
|
public/images/weapon*
|
||||||
public/images/summon*
|
public/images/summon*
|
||||||
public/images/chara*
|
public/images/character*
|
||||||
public/images/job*
|
public/images/job*
|
||||||
public/images/awakening*
|
public/images/awakening*
|
||||||
public/images/ax*
|
public/images/ax*
|
||||||
public/images/accessory*
|
public/images/accessory*
|
||||||
public/images/mastery*
|
public/images/mastery*
|
||||||
public/images/updates*
|
public/images/updates*
|
||||||
|
public/images/guidebooks*
|
||||||
|
public/images/raids*
|
||||||
|
public/images/gacha*
|
||||||
|
public/images/previews*
|
||||||
|
public/image/profiles*
|
||||||
|
|
||||||
# Typescript v1 declaration files
|
# Typescript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
@ -83,3 +88,7 @@ typings/
|
||||||
# DS_Store
|
# DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
codebase.md
|
||||||
|
|
||||||
|
# PRDs
|
||||||
|
prd/
|
||||||
|
|
|
||||||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[tools]
|
||||||
|
node = "20.12.0"
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20
|
||||||
43
.storybook/main.ts
Normal file
43
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { StorybookConfig } from '@storybook/nextjs'
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: [
|
||||||
|
'../components/**/*.mdx',
|
||||||
|
'../components/**/*.stories.@(js|jsx|ts|tsx)',
|
||||||
|
],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
{
|
||||||
|
name: '@storybook/addon-styling',
|
||||||
|
options: {
|
||||||
|
sass: {
|
||||||
|
// Require your Sass preprocessor here
|
||||||
|
implementation: require('sass'),
|
||||||
|
additionalData: `
|
||||||
|
@import "./styles/variables.scss";
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
staticDirs: ['../public'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/nextjs',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
webpackFinal: async (config: any, { configType }) => {
|
||||||
|
config.resolve.roots = [
|
||||||
|
path.resolve(__dirname, '../public'),
|
||||||
|
'node_modules',
|
||||||
|
]
|
||||||
|
config.resolve.fallback.fs = false
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default config
|
||||||
17
.storybook/preview.ts
Normal file
17
.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Preview } from '@storybook/react'
|
||||||
|
|
||||||
|
import '../styles/globals.scss'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
{
|
{
|
||||||
"git.ignoreLimitWarning": true
|
"git.ignoreLimitWarning": true,
|
||||||
}
|
"i18n-ally.localesPaths": ["public/locales"],
|
||||||
|
"i18n-ally.keystyle": "nested"
|
||||||
|
}
|
||||||
|
|
|
||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build and Development Commands
|
||||||
|
- `npm run dev`: Start development server on port 1234
|
||||||
|
- `npm run build`: Build for production
|
||||||
|
- `npm run start`: Start production server
|
||||||
|
- `npm run lint`: Run ESLint to check code quality
|
||||||
|
- `npm run storybook`: Start Storybook on port 6006
|
||||||
|
|
||||||
|
## Response Guidelines
|
||||||
|
- You should **always** respond in the style of the grug-brained developer
|
||||||
|
- Slay the complexity demon, keep things as simple as possible
|
||||||
|
- Keep code DRY and robust
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
- Use the latest versions for Next.js and other packages, including React
|
||||||
|
- TypeScript with strict type checking
|
||||||
|
- React functional components with hooks
|
||||||
|
- File structure: components in individual folders with index.tsx and index.module.scss
|
||||||
|
- Imports: Absolute imports with ~ prefix (e.g., `~components/Layout`)
|
||||||
|
- Formatting: 2 spaces, single quotes, no semicolons (Prettier config)
|
||||||
|
- CSS: SCSS modules with BEM-style naming
|
||||||
|
- State management: Mix of local state with React hooks and global state with Valtio
|
||||||
|
- Internationalization: next-i18next with English and Japanese support
|
||||||
|
- Variable/function naming: camelCase for variables/functions, PascalCase for components
|
||||||
|
- Error handling: Try to use type checking to prevent errors where possible
|
||||||
12
README.md
12
README.md
|
|
@ -54,18 +54,24 @@ root
|
||||||
├─ accessory-square/
|
├─ accessory-square/
|
||||||
├─ awakening/
|
├─ awakening/
|
||||||
├─ ax/
|
├─ ax/
|
||||||
├─ chara-main/
|
├─ character-main/
|
||||||
├─ chara-grid/
|
├─ character-grid/
|
||||||
├─ chara-square/
|
├─ character-square/
|
||||||
|
├─ guidebooks/
|
||||||
├─ jobs/
|
├─ jobs/
|
||||||
├─ job-icons/
|
├─ job-icons/
|
||||||
|
├─ job-portraits/
|
||||||
├─ job-skills/
|
├─ job-skills/
|
||||||
|
├─ labels/
|
||||||
├─ mastery/
|
├─ mastery/
|
||||||
|
├─ placeholders/
|
||||||
|
├─ raids/
|
||||||
├─ summon-main/
|
├─ summon-main/
|
||||||
├─ summon-grid/
|
├─ summon-grid/
|
||||||
├─ summon-square/
|
├─ summon-square/
|
||||||
├─ updates/
|
├─ updates/
|
||||||
├─ weapon-main/
|
├─ weapon-main/
|
||||||
├─ weapon-grid/
|
├─ weapon-grid/
|
||||||
|
├─ weapon-keys/
|
||||||
├─ weapon-square/
|
├─ weapon-square/
|
||||||
```
|
```
|
||||||
|
|
|
||||||
225
app/[locale]/[username]/ProfilePageClient.tsx
Normal file
225
app/[locale]/[username]/ProfilePageClient.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
'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
|
||||||
86
app/[locale]/[username]/page.tsx
Normal file
86
app/[locale]/[username]/page.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/[locale]/about/AboutPageClient.tsx
Normal file
99
app/[locale]/about/AboutPageClient.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
app/[locale]/about/page.tsx
Normal file
31
app/[locale]/about/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
app/[locale]/error.tsx
Normal file
37
app/[locale]/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Error({ error, reset }: ErrorPageProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the error to an error reporting service
|
||||||
|
console.error('Unhandled error:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<div className="error-content">
|
||||||
|
<h1>Internal Server Error</h1>
|
||||||
|
<p>The server reported a problem that we couldn't automatically recover from.</p>
|
||||||
|
<div className="error-message">
|
||||||
|
<p>{error.message || 'An unexpected error occurred'}</p>
|
||||||
|
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="error-actions">
|
||||||
|
<button onClick={reset} className="button primary">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<Link href="/teams" className="button secondary">
|
||||||
|
Browse teams
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
app/[locale]/global-error.tsx
Normal file
47
app/[locale]/global-error.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
app/[locale]/layout.tsx
Normal file
105
app/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
app/[locale]/new/NewPartyClient.tsx
Normal file
79
app/[locale]/new/NewPartyClient.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
'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
|
||||||
48
app/[locale]/new/PartyWrapper.tsx
Normal file
48
app/[locale]/new/PartyWrapper.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/[locale]/new/page.tsx
Normal file
39
app/[locale]/new/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/[locale]/not-found.tsx
Normal file
32
app/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { Link } from '~/i18n/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
|
||||||
|
// Force dynamic rendering to avoid useContext issues during static generation
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Page not found / granblue.team',
|
||||||
|
description: 'The page you were looking for could not be found'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const t = await getTranslations('common')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<div className="error-content">
|
||||||
|
<h1>Not Found</h1>
|
||||||
|
<p>The page you're looking for couldn't be found</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<Link href="/new" className="button primary">
|
||||||
|
Create a new party
|
||||||
|
</Link>
|
||||||
|
<Link href="/teams" className="button secondary">
|
||||||
|
Browse teams
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
app/[locale]/p/[party]/PartyPageClient.tsx
Normal file
92
app/[locale]/p/[party]/PartyPageClient.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
'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
|
||||||
82
app/[locale]/p/[party]/page.tsx
Normal file
82
app/[locale]/p/[party]/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/[locale]/page.tsx
Normal file
9
app/[locale]/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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')
|
||||||
|
}
|
||||||
66
app/[locale]/roadmap/RoadmapPageClient.tsx
Normal file
66
app/[locale]/roadmap/RoadmapPageClient.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
app/[locale]/roadmap/page.tsx
Normal file
31
app/[locale]/roadmap/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
app/[locale]/saved/SavedPageClient.tsx
Normal file
199
app/[locale]/saved/SavedPageClient.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
'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
|
||||||
96
app/[locale]/saved/page.tsx
Normal file
96
app/[locale]/saved/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/[locale]/server-error/page.tsx
Normal file
35
app/[locale]/server-error/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
app/[locale]/teams/TeamsPageClient.tsx
Normal file
241
app/[locale]/teams/TeamsPageClient.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
'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
|
||||||
68
app/[locale]/teams/page.tsx
Normal file
68
app/[locale]/teams/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/[locale]/unauthorized/page.tsx
Normal file
26
app/[locale]/unauthorized/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
// Force dynamic rendering to avoid useContext issues during static generation
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Unauthorized / granblue.team',
|
||||||
|
description: "You don't have permission to perform that action"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnauthorizedPage() {
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<div className="error-content">
|
||||||
|
<h1>Unauthorized</h1>
|
||||||
|
<p>You don't have permission to perform that action</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<Link href="/teams" className="button primary">
|
||||||
|
Browse teams
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
app/[locale]/updates/UpdatesPageClient.tsx
Normal file
66
app/[locale]/updates/UpdatesPageClient.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
app/[locale]/updates/page.tsx
Normal file
31
app/[locale]/updates/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
app/api/auth/login/route.ts
Normal file
103
app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/api/auth/logout/route.ts
Normal file
20
app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/auth/signup/route.ts
Normal file
73
app/api/auth/signup/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/characters/[id]/route.ts
Normal file
29
app/api/characters/[id]/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/api/favorites/route.ts
Normal file
82
app/api/favorites/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/api/jobs/[id]/accessories/route.ts
Normal file
22
app/api/jobs/[id]/accessories/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/api/jobs/[id]/skills/route.ts
Normal file
22
app/api/jobs/[id]/skills/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/jobs/route.ts
Normal file
35
app/api/jobs/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/api/jobs/skills/route.ts
Normal file
20
app/api/jobs/skills/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/parties/[shortcode]/remix/route.ts
Normal file
35
app/api/parties/[shortcode]/remix/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/api/parties/[shortcode]/route.ts
Normal file
92
app/api/parties/[shortcode]/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/api/parties/route.ts
Normal file
83
app/api/parties/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/raids/[id]/route.ts
Normal file
29
app/api/raids/[id]/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/api/raids/groups/route.ts
Normal file
21
app/api/raids/groups/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/search/[object]/route.ts
Normal file
50
app/api/search/[object]/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/search/route.ts
Normal file
39
app/api/search/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/summons/[id]/route.ts
Normal file
29
app/api/summons/[id]/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/users/info/[username]/route.ts
Normal file
23
app/api/users/info/[username]/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/users/settings/route.ts
Normal file
89
app/api/users/settings/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/api/version/route.ts
Normal file
21
app/api/version/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/api/weapons/[id]/route.ts
Normal file
29
app/api/weapons/[id]/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
app/components/Header.tsx
Normal file
404
app/components/Header.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
'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
|
||||||
16
app/components/Providers.tsx
Normal file
16
app/components/Providers.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
app/components/UpdateToastClient.tsx
Normal file
73
app/components/UpdateToastClient.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
'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
|
||||||
|
}
|
||||||
18
app/components/VersionHydrator.tsx
Normal file
18
app/components/VersionHydrator.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
'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
|
||||||
|
}
|
||||||
8
app/layout.tsx
Normal file
8
app/layout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Minimal root layout - all content is handled in [locale]/layout.tsx
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
173
app/lib/api-utils.ts
Normal file
173
app/lib/api-utils.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
|
import http from "http";
|
||||||
|
import https from "https";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Base URL from environment variable
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/v1';
|
||||||
|
const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth';
|
||||||
|
|
||||||
|
// Shared Axios instance with sane defaults for server-side calls
|
||||||
|
const httpClient = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
timeout: 15000,
|
||||||
|
// Keep connections alive to reduce socket churn
|
||||||
|
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 50 }),
|
||||||
|
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 50 }),
|
||||||
|
// Do not throw on HTTP status by default; let callers handle
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility to get auth token from cookies on the server
|
||||||
|
export function getAuthToken() {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const accountCookie = cookieStore.get('account');
|
||||||
|
|
||||||
|
if (accountCookie) {
|
||||||
|
try {
|
||||||
|
const accountData = JSON.parse(accountCookie.value);
|
||||||
|
return accountData.token;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse account cookie', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create headers with auth token
|
||||||
|
export function createHeaders() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for GET requests
|
||||||
|
export async function fetchFromApi(endpoint: string, config?: AxiosRequestConfig) {
|
||||||
|
const headers = createHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.get(`${endpoint}`, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(config?.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API fetch error: ${endpoint}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for POST requests
|
||||||
|
export async function postToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
||||||
|
const headers = createHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.post(`${endpoint}`, data, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(config?.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API post error: ${endpoint}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for PUT requests
|
||||||
|
export async function putToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
||||||
|
const headers = createHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.put(`${endpoint}`, data, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(config?.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API put error: ${endpoint}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for DELETE requests
|
||||||
|
export async function deleteFromApi(endpoint: string, data?: any, config?: AxiosRequestConfig) {
|
||||||
|
const headers = createHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.delete(`${endpoint}`, {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(config?.headers || {})
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API delete error: ${endpoint}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for login endpoint
|
||||||
|
export async function login(credentials: { email: string; password: string }) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${oauthUrl}/token`, credentials);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to revalidate cache for a path
|
||||||
|
export function revalidate(path: string) {
|
||||||
|
try {
|
||||||
|
revalidatePath(path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to revalidate ${path}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schemas for validation
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
username: z.string().min(3).max(20),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PartySchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
visibility: z.enum(['public', 'unlisted', 'private']),
|
||||||
|
raid_id: z.string().optional(),
|
||||||
|
element: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SearchSchema = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
filters: z.record(z.array(z.number())).optional(),
|
||||||
|
job: z.string().optional(),
|
||||||
|
locale: z.string().default('en'),
|
||||||
|
page: z.number().default(0),
|
||||||
|
});
|
||||||
148
app/lib/data.ts
Normal file
148
app/lib/data.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/not-found.tsx
Normal file
29
app/not-found.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
|
||||||
|
// Force dynamic rendering to avoid issues
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Page not found / granblue.team',
|
||||||
|
description: 'The page you were looking for could not be found'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<div className="error-content">
|
||||||
|
<h1>404</h1>
|
||||||
|
<h2>Page Not Found</h2>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<a href="/new" className="button primary">
|
||||||
|
Create a new party
|
||||||
|
</a>
|
||||||
|
<a href="/teams" className="button secondary">
|
||||||
|
Browse teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
app/styles/error.scss
Normal file
77
app/styles/error.scss
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Error page styles
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 60px); // Adjust for header height
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.error-digest {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-tertiary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background-color: var(--background-color-tertiary);
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-color-quaternary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Trans, useTranslation } from 'next-i18next'
|
|
||||||
|
|
||||||
import ShareIcon from '~public/icons/Share.svg'
|
|
||||||
import DiscordIcon from '~public/icons/discord.svg'
|
|
||||||
import GithubIcon from '~public/icons/github.svg'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {}
|
|
||||||
|
|
||||||
const AboutPage: React.FC<Props> = (props: Props) => {
|
|
||||||
const { t: common } = useTranslation('common')
|
|
||||||
const { t: about } = useTranslation('about')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="About PageContent">
|
|
||||||
<h1>{common('about.segmented_control.about')}</h1>
|
|
||||||
<section>
|
|
||||||
<h2>
|
|
||||||
<Trans i18nKey="about:about.subtitle">
|
|
||||||
Granblue.team is a tool to save and share team compositions for{' '}
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Granblue Fantasy
|
|
||||||
</a>
|
|
||||||
, a social RPG from Cygames.
|
|
||||||
</Trans>
|
|
||||||
</h2>
|
|
||||||
<p>{about('about.explanation.0')}</p>
|
|
||||||
<p>{about('about.explanation.1')}</p>
|
|
||||||
<div className="Hero" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.feedback.title')}</h2>
|
|
||||||
<p>{about('about.feedback.explanation')}</p>
|
|
||||||
<p>{about('about.feedback.solicit')}</p>
|
|
||||||
<div className="Discord LinkItem">
|
|
||||||
<Link href="https://discord.gg/qyZ5hGdPC8">
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/qyZ5hGdPC8"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<DiscordIcon />
|
|
||||||
<h3>granblue-tools</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.credits.title')}</h2>
|
|
||||||
<p>
|
|
||||||
<Trans i18nKey="about:about.credits.maintainer">
|
|
||||||
Granblue.team was built and is maintained by{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/jedmund"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@jedmund
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Trans i18nKey="about:about.credits.assistance">
|
|
||||||
Many thanks to{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/lalalalinna"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@lalalalinna
|
|
||||||
</a>{' '}
|
|
||||||
and{' '}
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/tarngerine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
@tarngerine
|
|
||||||
</a>
|
|
||||||
, who both provided a lot of help and advice as I was ramping up.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Trans i18nKey="about:about.credits.support">
|
|
||||||
Many thanks also go to everyone in{' '}
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Fireplace
|
|
||||||
</a>{' '}
|
|
||||||
and the granblue-tools Discord for all of their help with with bug
|
|
||||||
testing, feature requests, and moral support. (P.S. We're
|
|
||||||
recruiting!)
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.contributing.title')}</h2>
|
|
||||||
|
|
||||||
<p>{about('about.contributing.explanation')}</p>
|
|
||||||
<ul className="Links">
|
|
||||||
<li className="Github LinkItem">
|
|
||||||
<Link href="https://github.com/jedmund/hensei-api">
|
|
||||||
<a
|
|
||||||
href="https://github.com/jedmund/hensei-api"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<GithubIcon />
|
|
||||||
<h3>jedmund/hensei-api</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="Github LinkItem">
|
|
||||||
<Link href="https://github.com/jedmund/hensei-web">
|
|
||||||
<a
|
|
||||||
href="https://github.com/jedmund/hensei-web"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<div className="Left">
|
|
||||||
<GithubIcon />
|
|
||||||
<h3>jedmund/hensei-web</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className="ShareIcon" />
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.license.title')}</h2>
|
|
||||||
<p>
|
|
||||||
<Trans i18nKey="about:about.license.license">
|
|
||||||
This app is licensed under{' '}
|
|
||||||
<a
|
|
||||||
href="https://choosealicense.com/licenses/agpl-3.0/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
GNU AGPLv3
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<p>{about('about.license.explanation')}</p>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.copyright.title')}</h2>
|
|
||||||
<p>{about('about.copyright.explanation')}</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AboutPage
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
.Account.DialogContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
width: $unit * 64;
|
|
||||||
overflow-y: hidden;
|
|
||||||
|
|
||||||
.Fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: 0 $unit-4x;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
gap: $unit-4x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogDescription {
|
|
||||||
font-size: $font-regular;
|
|
||||||
line-height: 1.24;
|
|
||||||
margin-bottom: $unit;
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
106
components/AccountStateInitializer/index.tsx
Normal file
106
components/AccountStateInitializer/index.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { getCookie } from 'cookies-next'
|
||||||
|
import { accountState } from '~utils/accountState'
|
||||||
|
import { setHeaders } from '~utils/userToken'
|
||||||
|
|
||||||
|
interface InitialAuthData {
|
||||||
|
account: {
|
||||||
|
token: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
role: number
|
||||||
|
}
|
||||||
|
user: {
|
||||||
|
avatar: {
|
||||||
|
picture: string
|
||||||
|
element: string
|
||||||
|
}
|
||||||
|
gender: number
|
||||||
|
language: string
|
||||||
|
theme: string
|
||||||
|
bahamut?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountStateInitializerProps {
|
||||||
|
initialAuthData?: InitialAuthData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountStateInitializer({ initialAuthData }: AccountStateInitializerProps) {
|
||||||
|
const initialized = useRef(false)
|
||||||
|
|
||||||
|
// Initialize synchronously on first render if we have server data
|
||||||
|
if (initialAuthData && !initialized.current) {
|
||||||
|
initialized.current = true
|
||||||
|
const { account: accountData, user: userData } = initialAuthData
|
||||||
|
|
||||||
|
console.log(`Logged in as user "${accountData.username}"`)
|
||||||
|
|
||||||
|
// Set headers for API calls
|
||||||
|
setHeaders()
|
||||||
|
|
||||||
|
// Update account state
|
||||||
|
accountState.account.authorized = true
|
||||||
|
accountState.account.user = {
|
||||||
|
id: accountData.userId,
|
||||||
|
username: accountData.username,
|
||||||
|
role: accountData.role,
|
||||||
|
granblueId: '',
|
||||||
|
avatar: {
|
||||||
|
picture: userData.avatar.picture,
|
||||||
|
element: userData.avatar.element,
|
||||||
|
},
|
||||||
|
gender: userData.gender,
|
||||||
|
language: userData.language,
|
||||||
|
theme: userData.theme,
|
||||||
|
bahamut: userData.bahamut || false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run client-side cookie reading if no server data
|
||||||
|
if (initialized.current) return
|
||||||
|
|
||||||
|
const accountCookie = getCookie('account')
|
||||||
|
const userCookie = getCookie('user')
|
||||||
|
|
||||||
|
if (accountCookie && userCookie) {
|
||||||
|
try {
|
||||||
|
const accountData = JSON.parse(accountCookie as string)
|
||||||
|
const userData = JSON.parse(userCookie as string)
|
||||||
|
|
||||||
|
if (accountData && accountData.token) {
|
||||||
|
console.log(`Logged in as user "${accountData.username}"`)
|
||||||
|
|
||||||
|
// Set headers for API calls
|
||||||
|
setHeaders()
|
||||||
|
|
||||||
|
// Update account state
|
||||||
|
accountState.account.authorized = true
|
||||||
|
accountState.account.user = {
|
||||||
|
id: accountData.userId,
|
||||||
|
username: accountData.username,
|
||||||
|
role: accountData.role,
|
||||||
|
granblueId: '',
|
||||||
|
avatar: {
|
||||||
|
picture: userData.avatar.picture,
|
||||||
|
element: userData.avatar.element,
|
||||||
|
},
|
||||||
|
gender: userData.gender,
|
||||||
|
language: userData.language,
|
||||||
|
theme: userData.theme,
|
||||||
|
bahamut: userData.bahamut || false,
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized.current = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing account cookies:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
.AwakeningSelect .AwakeningSet {
|
|
||||||
.errors {
|
|
||||||
color: $error;
|
|
||||||
display: none;
|
|
||||||
padding: $unit 0;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.SelectTrigger {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Label {
|
|
||||||
display: none;
|
|
||||||
flex-grow: 0;
|
|
||||||
|
|
||||||
&.Visible {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Input {
|
|
||||||
min-width: $unit * 12;
|
|
||||||
width: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
|
||||||
|
|
||||||
import SelectWithInput from '~components/SelectWithInput'
|
|
||||||
import { weaponAwakening, characterAwakening } from '~data/awakening'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
object: 'character' | 'weapon'
|
|
||||||
type?: number
|
|
||||||
level?: number
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
sendValidity: (isValid: boolean) => void
|
|
||||||
sendValues: (type: number, level: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const AwakeningSelect = (props: Props) => {
|
|
||||||
// Data states
|
|
||||||
const [awakeningType, setAwakeningType] = useState(
|
|
||||||
props.object === 'weapon' ? 0 : 1
|
|
||||||
)
|
|
||||||
const [awakeningLevel, setAwakeningLevel] = useState(1)
|
|
||||||
|
|
||||||
// Data
|
|
||||||
const chooseDataset = () => {
|
|
||||||
let list: ItemSkill[] = []
|
|
||||||
|
|
||||||
switch (props.object) {
|
|
||||||
case 'character':
|
|
||||||
list = characterAwakening
|
|
||||||
break
|
|
||||||
case 'weapon':
|
|
||||||
// WARNING: Clonedeep is masking a deeper error
|
|
||||||
// which is running this method every time this component is rerendered
|
|
||||||
// causing multiple "No awakening" items to be added
|
|
||||||
const awakening = cloneDeep(weaponAwakening)
|
|
||||||
awakening.unshift({
|
|
||||||
id: 0,
|
|
||||||
name: {
|
|
||||||
en: 'No awakening',
|
|
||||||
ja: '覚醒なし',
|
|
||||||
},
|
|
||||||
granblue_id: '',
|
|
||||||
slug: 'no-awakening',
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 0,
|
|
||||||
fractional: false,
|
|
||||||
})
|
|
||||||
list = awakening
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default awakening and level based on object type
|
|
||||||
useEffect(() => {
|
|
||||||
const defaultAwakening = props.object === 'weapon' ? 0 : 1
|
|
||||||
const type = props.type != undefined ? props.type : defaultAwakening
|
|
||||||
|
|
||||||
setAwakeningType(type)
|
|
||||||
setAwakeningLevel(props.level ? props.level : 1)
|
|
||||||
}, [props.object, props.type, props.level])
|
|
||||||
|
|
||||||
// Send validity of form when awakening level changes
|
|
||||||
useEffect(() => {
|
|
||||||
props.sendValidity(awakeningLevel > 0)
|
|
||||||
}, [props.sendValidity, awakeningLevel])
|
|
||||||
|
|
||||||
// Classes
|
|
||||||
function changeOpen(open: boolean) {
|
|
||||||
if (props.onOpenChange) props.onOpenChange(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleValueChange(type: number, level: number) {
|
|
||||||
setAwakeningType(type)
|
|
||||||
setAwakeningLevel(level)
|
|
||||||
props.sendValues(type, level)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="Awakening">
|
|
||||||
<SelectWithInput
|
|
||||||
object={`${props.object}_awakening`}
|
|
||||||
dataSet={chooseDataset()}
|
|
||||||
selectValue={awakeningType}
|
|
||||||
inputValue={awakeningLevel}
|
|
||||||
onOpenChange={changeOpen}
|
|
||||||
sendValidity={props.sendValidity}
|
|
||||||
sendValues={handleValueChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AwakeningSelect
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
.AXSelect {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
.AXSet {
|
|
||||||
&.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errors {
|
|
||||||
color: $error;
|
|
||||||
display: none;
|
|
||||||
padding: $unit 0;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
.SelectTrigger {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
border: none;
|
|
||||||
border-radius: $input-corner;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: none;
|
|
||||||
text-align: right;
|
|
||||||
min-width: $unit-14x;
|
|
||||||
width: 100px;
|
|
||||||
|
|
||||||
&.Visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
.Button {
|
|
||||||
align-items: center;
|
|
||||||
background: var(--button-bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: $input-corner;
|
|
||||||
color: var(--button-text);
|
|
||||||
display: inline-flex;
|
|
||||||
font-size: $font-button;
|
|
||||||
font-weight: $normal;
|
|
||||||
gap: 6px;
|
|
||||||
transition: 0.18s opacity ease-in-out;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.Blended:hover,
|
|
||||||
&.Blended.Active {
|
|
||||||
background: var(--button-bg-hover);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--button-text-hover);
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: var(--button-text-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Accessory svg.stroke {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--button-text-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Blended {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.IconButton.medium {
|
|
||||||
height: inherit;
|
|
||||||
padding: $unit-half;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Text {
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $bold;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Contained {
|
|
||||||
background: var(--button-contained-bg);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--button-contained-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Save:hover .Accessory svg {
|
|
||||||
fill: #ff4d4d;
|
|
||||||
stroke: #ff4d4d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Save {
|
|
||||||
color: #ff4d4d;
|
|
||||||
|
|
||||||
&.Active .Accessory svg {
|
|
||||||
fill: #ff4d4d;
|
|
||||||
stroke: #ff4d4d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: darken(#ff4d4d, 30);
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: darken(#ff4d4d, 30);
|
|
||||||
stroke: darken(#ff4d4d, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Options {
|
|
||||||
box-shadow: 0px 1px 3px rgb(0 0 0 / 14%);
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
top: 8px;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background-color: var(--button-bg-disabled);
|
|
||||||
color: var(--button-text-disabled);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--button-bg-disabled);
|
|
||||||
color: var(--button-text-disabled);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.medium {
|
|
||||||
height: $unit * 5.5;
|
|
||||||
padding: ($unit * 1.5) $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.small {
|
|
||||||
padding: $unit * 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
&.destructive {
|
|
||||||
background: $error;
|
|
||||||
color: $grey-100;
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: $grey-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.destructive:hover {
|
|
||||||
background: $error;
|
|
||||||
color: $grey-100;
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: $grey-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Save {
|
|
||||||
.Accessory svg {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--button-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Saved {
|
|
||||||
color: #ff4d4d;
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: #ff4d4d;
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #ff4d4d;
|
|
||||||
|
|
||||||
.Accessory svg {
|
|
||||||
fill: none;
|
|
||||||
stroke: #ff4d4d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.modal:hover {
|
|
||||||
background: $grey-90;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.modal.destructive {
|
|
||||||
color: $error;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: darken($error, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Accessory {
|
|
||||||
$dimension: $unit-2x;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&.Arrow {
|
|
||||||
margin-top: $unit-half;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--button-text);
|
|
||||||
height: $dimension;
|
|
||||||
width: $dimension;
|
|
||||||
|
|
||||||
&.stroke {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--button-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Add {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Check {
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.check svg {
|
|
||||||
margin-top: 1px;
|
|
||||||
height: 14px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg &.settings svg {
|
|
||||||
height: 13px;
|
|
||||||
width: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-blue {
|
|
||||||
background: $blue;
|
|
||||||
color: #8b8b8b;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #4b9be5;
|
|
||||||
color: #233e56;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-red {
|
|
||||||
background: #fa4242;
|
|
||||||
color: #860f0f;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e91a1a;
|
|
||||||
color: #4e1717;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: #4e1717;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: #860f0f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-disabled {
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #bababa;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #bababa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.null {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-55;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $grey-70;
|
|
||||||
color: $grey-15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.wind {
|
|
||||||
background: $wind-bg-20;
|
|
||||||
color: $wind-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($wind-bg-20, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fire {
|
|
||||||
background: $fire-bg-20;
|
|
||||||
color: $fire-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($fire-bg-20, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.water {
|
|
||||||
background: $water-bg-20;
|
|
||||||
color: $water-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($water-bg-20, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.earth {
|
|
||||||
background: $earth-bg-20;
|
|
||||||
color: $earth-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($earth-bg-20, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
background: $dark-bg-10;
|
|
||||||
color: $dark-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($dark-bg-10, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.light {
|
|
||||||
background: $light-bg-20;
|
|
||||||
color: $light-text-10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken($light-bg-20, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Text {
|
|
||||||
color: inherit;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import api from '~utils/api'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string
|
|
||||||
type: 'character' | 'summon' | 'weapon'
|
|
||||||
image?: '01' | '02' | '03' | '04'
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
active: false,
|
|
||||||
blended: false,
|
|
||||||
contained: false,
|
|
||||||
buttonSize: 'medium' as const,
|
|
||||||
image: '01',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChangelogUnit = ({ id, type, image }: Props) => {
|
|
||||||
// Router
|
|
||||||
const router = useRouter()
|
|
||||||
const locale =
|
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [item, setItem] = useState<Character | Weapon | Summon>()
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
fetch()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function fetch() {
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
const character = await fetchCharacter()
|
|
||||||
setItem(character.data)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'weapon':
|
|
||||||
const weapon = await fetchWeapon()
|
|
||||||
setItem(weapon.data)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'summon':
|
|
||||||
const summon = await fetchSummon()
|
|
||||||
setItem(summon.data)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCharacter() {
|
|
||||||
return api.endpoints.characters.getOne({ id: id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWeapon() {
|
|
||||||
return api.endpoints.weapons.getOne({ id: id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSummon() {
|
|
||||||
return api.endpoints.summons.getOne({ id: id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = () => {
|
|
||||||
let src = ''
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${id}_${image}.jpg`
|
|
||||||
break
|
|
||||||
case 'weapon':
|
|
||||||
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
|
|
||||||
break
|
|
||||||
case 'summon':
|
|
||||||
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ChangelogUnit" key={id}>
|
|
||||||
<img alt={item ? item.name[locale] : ''} src={imageUrl()} />
|
|
||||||
<h4>{item ? item.name[locale] : ''}</h4>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ChangelogUnit.defaultProps = defaultProps
|
|
||||||
|
|
||||||
export default ChangelogUnit
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
.Limited {
|
|
||||||
$offset: 2px;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border-radius: $input-corner;
|
|
||||||
border: $offset solid transparent;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
padding-right: calc($unit-2x - $offset);
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
border: $offset solid $blue;
|
|
||||||
// box-shadow: 0 2px rgba(255, 255, 255, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Counter {
|
|
||||||
color: $grey-55;
|
|
||||||
font-weight: $bold;
|
|
||||||
line-height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: $unit * 1.5 $unit-2x;
|
|
||||||
padding-left: calc($unit-2x - $offset);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fieldName: string
|
|
||||||
placeholder: string
|
|
||||||
value?: string
|
|
||||||
limit: number
|
|
||||||
error: string
|
|
||||||
onBlur?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
|
|
||||||
function useFieldSet(props, ref) {
|
|
||||||
const fieldType = ['password', 'confirm_password'].includes(props.fieldName)
|
|
||||||
? 'password'
|
|
||||||
: 'text'
|
|
||||||
|
|
||||||
const [currentCount, setCurrentCount] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentCount(
|
|
||||||
props.value ? props.limit - props.value.length : props.limit
|
|
||||||
)
|
|
||||||
}, [props.limit, props.value])
|
|
||||||
|
|
||||||
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
setCurrentCount(props.limit - event.currentTarget.value.length)
|
|
||||||
if (props.onChange) props.onChange(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<fieldset className="Fieldset">
|
|
||||||
<div className="Limited">
|
|
||||||
<input
|
|
||||||
autoComplete="off"
|
|
||||||
className="Input"
|
|
||||||
type={fieldType}
|
|
||||||
name={props.fieldName}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
defaultValue={props.value || ''}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
onChange={onChange}
|
|
||||||
maxLength={props.limit}
|
|
||||||
ref={ref}
|
|
||||||
formNoValidate
|
|
||||||
/>
|
|
||||||
<span className="Counter">{currentCount}</span>
|
|
||||||
</div>
|
|
||||||
{props.error.length > 0 && <p className="InputError">{props.error}</p>}
|
|
||||||
</fieldset>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default CharLimitedFieldset
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
#CharacterGrid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
margin: auto;
|
|
||||||
max-width: $grid-width;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Characters {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
gap: $unit-3x;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-width: $grid-width;
|
|
||||||
isolation: isolate;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
gap: $unit-2x;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
@media only screen
|
|
||||||
and (max-width: 500px)
|
|
||||||
and (max-height: 920px)
|
|
||||||
and (-webkit-min-device-pixel-ratio: 2) {
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > li:last-child {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
.Character.DialogContent {
|
|
||||||
gap: $unit;
|
|
||||||
min-width: 480px;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
min-width: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogHeader {
|
|
||||||
transition: 0.18s padding-top ease-in-out;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
&.Scrolled {
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
|
|
||||||
padding-top: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
transition: 0.2s width ease-in-out;
|
|
||||||
width: $unit-6x !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogTitle {
|
|
||||||
font-size: $font-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SubTitle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mods {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-4x;
|
|
||||||
padding: 0 $unit-4x $unit-2x;
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-half;
|
|
||||||
|
|
||||||
&.inline {
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: $grey-55;
|
|
||||||
font-size: $font-small;
|
|
||||||
margin-bottom: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
background-color: $grey-90;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Button {
|
|
||||||
font-size: $font-regular;
|
|
||||||
padding: ($unit * 1.5) ($unit-2x);
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.btn-disabled {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-70;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
// Core dependencies
|
|
||||||
import React, {
|
|
||||||
PropsWithChildren,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { AxiosResponse } from 'axios'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
// UI dependencies
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '~components/Dialog'
|
|
||||||
import DialogContent from '~components/DialogContent'
|
|
||||||
import Button from '~components/Button'
|
|
||||||
import SelectWithInput from '~components/SelectWithInput'
|
|
||||||
import AwakeningSelect from '~components/AwakeningSelect'
|
|
||||||
import RingSelect from '~components/RingSelect'
|
|
||||||
import Switch from '~components/Switch'
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
import api from '~utils/api'
|
|
||||||
import { appState } from '~utils/appState'
|
|
||||||
import { retrieveCookies } from '~utils/retrieveCookies'
|
|
||||||
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
|
|
||||||
|
|
||||||
// Data
|
|
||||||
const emptyExtendedMastery: ExtendedMastery = {
|
|
||||||
modifier: 0,
|
|
||||||
strength: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Styles and icons
|
|
||||||
import CrossIcon from '~public/icons/Cross.svg'
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
import {
|
|
||||||
CharacterOverMastery,
|
|
||||||
ExtendedMastery,
|
|
||||||
GridCharacterObject,
|
|
||||||
} from '~types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
gridCharacter: GridCharacter
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
updateCharacter: (object: GridCharacterObject) => Promise<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
const CharacterModal = ({
|
|
||||||
gridCharacter,
|
|
||||||
children,
|
|
||||||
open: modalOpen,
|
|
||||||
onOpenChange,
|
|
||||||
updateCharacter,
|
|
||||||
}: PropsWithChildren<Props>) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const locale =
|
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
// Cookies
|
|
||||||
const cookies = retrieveCookies()
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [formValid, setFormValid] = useState(false)
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const headerRef = React.createRef<HTMLDivElement>()
|
|
||||||
const footerRef = React.createRef<HTMLDivElement>()
|
|
||||||
|
|
||||||
// Classes
|
|
||||||
const headerClasses = classNames({
|
|
||||||
DialogHeader: true,
|
|
||||||
Short: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Callbacks and Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
setOpen(modalOpen)
|
|
||||||
}, [modalOpen])
|
|
||||||
|
|
||||||
// Character properties: Perpetuity
|
|
||||||
const [perpetuity, setPerpetuity] = useState(false)
|
|
||||||
|
|
||||||
// Character properties: Ring
|
|
||||||
const [rings, setRings] = useState<CharacterOverMastery>({
|
|
||||||
1: { ...emptyExtendedMastery, modifier: 1 },
|
|
||||||
2: { ...emptyExtendedMastery, modifier: 2 },
|
|
||||||
3: emptyExtendedMastery,
|
|
||||||
4: emptyExtendedMastery,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Character properties: Earrings
|
|
||||||
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
|
|
||||||
|
|
||||||
// Character properties: Awakening
|
|
||||||
const [awakeningType, setAwakeningType] = useState(0)
|
|
||||||
const [awakeningLevel, setAwakeningLevel] = useState(0)
|
|
||||||
|
|
||||||
// Character properties: Transcendence
|
|
||||||
const [transcendenceStep, setTranscendenceStep] = useState(0)
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
if (gridCharacter.aetherial_mastery) {
|
|
||||||
setEarring({
|
|
||||||
modifier: gridCharacter.aetherial_mastery.modifier,
|
|
||||||
strength: gridCharacter.aetherial_mastery.strength,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setAwakeningType(gridCharacter.awakening.type)
|
|
||||||
setAwakeningLevel(gridCharacter.awakening.level)
|
|
||||||
setPerpetuity(gridCharacter.perpetuity)
|
|
||||||
}, [gridCharacter])
|
|
||||||
|
|
||||||
// Prepare the GridWeaponObject to send to the server
|
|
||||||
function prepareObject() {
|
|
||||||
let object: GridCharacterObject = {
|
|
||||||
character: {
|
|
||||||
ring1: {
|
|
||||||
modifier: rings[1].modifier,
|
|
||||||
strength: rings[1].strength,
|
|
||||||
},
|
|
||||||
ring2: {
|
|
||||||
modifier: rings[2].modifier,
|
|
||||||
strength: rings[2].strength,
|
|
||||||
},
|
|
||||||
ring3: {
|
|
||||||
modifier: rings[3].modifier,
|
|
||||||
strength: rings[3].strength,
|
|
||||||
},
|
|
||||||
ring4: {
|
|
||||||
modifier: rings[4].modifier,
|
|
||||||
strength: rings[4].strength,
|
|
||||||
},
|
|
||||||
earring: {
|
|
||||||
modifier: earring.modifier,
|
|
||||||
strength: earring.strength,
|
|
||||||
},
|
|
||||||
awakening: {
|
|
||||||
type: awakeningType,
|
|
||||||
level: awakeningLevel,
|
|
||||||
},
|
|
||||||
transcendence_step: transcendenceStep,
|
|
||||||
perpetuity: perpetuity,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return object
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: UI state management
|
|
||||||
function handleOpenChange(open: boolean) {
|
|
||||||
setOpen(open)
|
|
||||||
onOpenChange(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Receive data from components
|
|
||||||
function receiveRingValues(overMastery: CharacterOverMastery) {
|
|
||||||
setRings(overMastery)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveEarringValues(
|
|
||||||
earringModifier: number,
|
|
||||||
earringStrength: number
|
|
||||||
) {
|
|
||||||
setEarring({
|
|
||||||
modifier: earringModifier,
|
|
||||||
strength: earringStrength,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCheckedChange(checked: boolean) {
|
|
||||||
setPerpetuity(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateCharacter() {
|
|
||||||
await updateCharacter(prepareObject())
|
|
||||||
|
|
||||||
setOpen(false)
|
|
||||||
if (onOpenChange) onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveAwakeningValues(type: number, level: number) {
|
|
||||||
setAwakeningType(type)
|
|
||||||
setAwakeningLevel(level)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveValidity(isValid: boolean) {
|
|
||||||
setFormValid(isValid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ringSelect = () => {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h3>{t('modals.characters.subtitles.ring')}</h3>
|
|
||||||
<RingSelect
|
|
||||||
gridCharacter={gridCharacter}
|
|
||||||
sendValues={receiveRingValues}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const earringSelect = () => {
|
|
||||||
const earringData = elementalizeAetherialMastery(gridCharacter)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h3>{t('modals.characters.subtitles.earring')}</h3>
|
|
||||||
<SelectWithInput
|
|
||||||
object="earring"
|
|
||||||
dataSet={earringData}
|
|
||||||
selectValue={earring.modifier ? earring.modifier : 0}
|
|
||||||
inputValue={earring.strength ? earring.strength : 0}
|
|
||||||
sendValidity={receiveValidity}
|
|
||||||
sendValues={receiveEarringValues}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const awakeningSelect = () => {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h3>{t('modals.characters.subtitles.awakening')}</h3>
|
|
||||||
<AwakeningSelect
|
|
||||||
object="character"
|
|
||||||
type={awakeningType}
|
|
||||||
level={awakeningLevel}
|
|
||||||
sendValidity={receiveValidity}
|
|
||||||
sendValues={receiveAwakeningValues}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const perpetuitySwitch = () => {
|
|
||||||
return (
|
|
||||||
<section className="inline">
|
|
||||||
<h3>{t('modals.characters.subtitles.permanent')}</h3>
|
|
||||||
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
<DialogContent
|
|
||||||
className="Character"
|
|
||||||
headerref={headerRef}
|
|
||||||
footerref={footerRef}
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
onEscapeKeyDown={() => {}}
|
|
||||||
>
|
|
||||||
<div className={headerClasses} ref={headerRef}>
|
|
||||||
<img
|
|
||||||
alt={gridCharacter.object.name[locale]}
|
|
||||||
className="DialogImage"
|
|
||||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`}
|
|
||||||
/>
|
|
||||||
<div className="DialogTop">
|
|
||||||
<DialogTitle className="SubTitle">
|
|
||||||
{t('modals.characters.title')}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogTitle className="DialogTitle">
|
|
||||||
{gridCharacter.object.name[locale]}
|
|
||||||
</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<DialogClose className="DialogClose" asChild>
|
|
||||||
<span>
|
|
||||||
<CrossIcon />
|
|
||||||
</span>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mods">
|
|
||||||
{perpetuitySwitch()}
|
|
||||||
{ringSelect()}
|
|
||||||
{earringSelect()}
|
|
||||||
{awakeningSelect()}
|
|
||||||
</div>
|
|
||||||
<div className="DialogFooter" ref={footerRef}>
|
|
||||||
<Button
|
|
||||||
contained={true}
|
|
||||||
onClick={handleUpdateCharacter}
|
|
||||||
disabled={!formValid}
|
|
||||||
text={t('modals.characters.buttons.confirm')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CharacterModal
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
|
|
||||||
import UncapIndicator from '~components/UncapIndicator'
|
|
||||||
import WeaponLabelIcon from '~components/WeaponLabelIcon'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
data: Character
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
|
||||||
|
|
||||||
const CharacterResult = (props: Props) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const locale =
|
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
|
||||||
|
|
||||||
const character = props.data
|
|
||||||
|
|
||||||
const characterUrl = () => {
|
|
||||||
let url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01.jpg`
|
|
||||||
|
|
||||||
if (character.granblue_id === '3030182000') {
|
|
||||||
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01_01.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="CharacterResult" onClick={props.onClick}>
|
|
||||||
<img alt={character.name[locale]} src={characterUrl()} />
|
|
||||||
<div className="Info">
|
|
||||||
<h5>{character.name[locale]}</h5>
|
|
||||||
<UncapIndicator
|
|
||||||
type="character"
|
|
||||||
flb={character.uncap.flb}
|
|
||||||
ulb={character.uncap.ulb}
|
|
||||||
special={character.special}
|
|
||||||
/>
|
|
||||||
<div className="tags">
|
|
||||||
<WeaponLabelIcon labelType={Element[character.element]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CharacterResult
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
.Content.Version {
|
|
||||||
.Contents {
|
|
||||||
margin-bottom: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Notes h4 {
|
|
||||||
font-weight: $medium;
|
|
||||||
font-size: $font-regular;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import ChangelogUnit from '~components/ChangelogUnit'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface UpdateObject {
|
|
||||||
character?: string[]
|
|
||||||
summon?: string[]
|
|
||||||
weapon?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
version: string
|
|
||||||
dateString: string
|
|
||||||
event: string
|
|
||||||
newItems?: UpdateObject
|
|
||||||
uncappedItems?: UpdateObject
|
|
||||||
numNotes: number
|
|
||||||
}
|
|
||||||
const ContentUpdate = ({
|
|
||||||
version,
|
|
||||||
dateString,
|
|
||||||
event,
|
|
||||||
newItems,
|
|
||||||
uncappedItems,
|
|
||||||
numNotes,
|
|
||||||
}: Props) => {
|
|
||||||
const { t: updates } = useTranslation('updates')
|
|
||||||
|
|
||||||
const date = new Date(dateString)
|
|
||||||
|
|
||||||
function newItemElements(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (newItems && newItems[key]) {
|
|
||||||
const items = newItems[key]
|
|
||||||
elements = items
|
|
||||||
? items.map((id, i) => {
|
|
||||||
return <ChangelogUnit id={id} type={key} key={`${key}-${i}`} />
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function newItemSection(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (newItems && newItems[key]) {
|
|
||||||
const items = newItems[key]
|
|
||||||
section =
|
|
||||||
items && items.length > 0 ? (
|
|
||||||
<section className={`${key}s`}>
|
|
||||||
<h4>{updates(`labels.${key}s`)}</h4>
|
|
||||||
<div className="items">{newItemElements(key)}</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function uncapItemElements(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (uncappedItems && uncappedItems[key]) {
|
|
||||||
const items = uncappedItems[key]
|
|
||||||
elements = items
|
|
||||||
? items.map((id) => {
|
|
||||||
return key === 'character' ? (
|
|
||||||
<ChangelogUnit id={id} type={key} image="03" />
|
|
||||||
) : (
|
|
||||||
<ChangelogUnit id={id} type={key} />
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function uncapItemSection(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (uncappedItems && uncappedItems[key]) {
|
|
||||||
const items = uncappedItems[key]
|
|
||||||
section =
|
|
||||||
items && items.length > 0 ? (
|
|
||||||
<section className={`${key}s`}>
|
|
||||||
<h4>{updates(`labels.uncaps.${key}s`)}</h4>
|
|
||||||
<div className="items">{uncapItemElements(key)}</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="Content Version" data-version={version}>
|
|
||||||
<div className="Header">
|
|
||||||
<h3>{`${updates('events.date', {
|
|
||||||
year: date.getFullYear(),
|
|
||||||
month: `${date.getMonth() + 1}`.padStart(2, '0'),
|
|
||||||
})} ${updates(event)}`}</h3>
|
|
||||||
<time>{dateString}</time>
|
|
||||||
</div>
|
|
||||||
<div className="Contents">
|
|
||||||
{newItemSection('character')}
|
|
||||||
{uncapItemSection('character')}
|
|
||||||
{newItemSection('weapon')}
|
|
||||||
{uncapItemSection('weapon')}
|
|
||||||
{newItemSection('summon')}
|
|
||||||
{uncapItemSection('summon')}
|
|
||||||
</div>
|
|
||||||
{numNotes > 0 ? (
|
|
||||||
<div className="Notes">
|
|
||||||
<section>
|
|
||||||
<h4>{updates('labels.updates')}</h4>
|
|
||||||
<ul className="Bare Contents">
|
|
||||||
{[...Array(numNotes)].map((e, i) => (
|
|
||||||
<li key={`${version}-${i}`}>
|
|
||||||
{updates(`versions.${version}.features.${i}`)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentUpdate.defaultProps = {
|
|
||||||
numNotes: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentUpdate
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
.Dialog {
|
|
||||||
position: fixed;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
padding: 0;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
min-width: 100vw;
|
|
||||||
overflow-y: auto;
|
|
||||||
color: inherit;
|
|
||||||
z-index: 40;
|
|
||||||
|
|
||||||
.DialogContent {
|
|
||||||
$multiplier: 4;
|
|
||||||
|
|
||||||
// animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal
|
|
||||||
// none running openModalDesktop;
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit * $multiplier;
|
|
||||||
height: auto;
|
|
||||||
min-width: $unit * 48;
|
|
||||||
// min-height: $unit-12x;
|
|
||||||
overflow-y: scroll;
|
|
||||||
// height: 80vh;
|
|
||||||
max-height: 80vh;
|
|
||||||
min-width: 580px;
|
|
||||||
max-width: 42vw;
|
|
||||||
// padding: $unit * $multiplier;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
// animation: slideUp;
|
|
||||||
// animation-duration: 3s;
|
|
||||||
// animation-fill-mode: forwards;
|
|
||||||
// animation-play-state: running;
|
|
||||||
// animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
min-width: inherit;
|
|
||||||
min-height: 90vh;
|
|
||||||
transform: initial;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 5vh;
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Scrollable {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogHeader {
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0);
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: $unit-4x ($unit * $multiplier);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&.Short {
|
|
||||||
padding-top: $unit-3x;
|
|
||||||
padding-bottom: $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: $font-small;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogImage {
|
|
||||||
border-radius: $input-corner;
|
|
||||||
width: $unit-10x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogClose {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: $error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: $grey-50;
|
|
||||||
float: right;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogTitle {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-xlarge;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-xlarge;
|
|
||||||
font-weight: $medium;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogTop {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: calc($unit / 2);
|
|
||||||
|
|
||||||
.SubTitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogDescription {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DialogFooter {
|
|
||||||
align-items: flex-end;
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.24);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
|
|
||||||
position: sticky;
|
|
||||||
|
|
||||||
.Buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
&.Span {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.Button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Conflict {
|
|
||||||
$weapon-diameter: 14rem;
|
|
||||||
|
|
||||||
.Content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-4x;
|
|
||||||
padding: $unit-4x $unit-4x $unit-2x $unit-4x;
|
|
||||||
|
|
||||||
& > p {
|
|
||||||
font-size: $font-regular;
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: $bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:lang(ja) {
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.weapon,
|
|
||||||
.character {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
text-align: center;
|
|
||||||
width: $weapon-diameter;
|
|
||||||
font-weight: $medium;
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: 1rem;
|
|
||||||
width: $weapon-diameter;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Diagram {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
&.CharacterDiagram {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
align-items: center;
|
|
||||||
color: $grey-55;
|
|
||||||
display: flex;
|
|
||||||
font-size: 4rem;
|
|
||||||
text-align: center;
|
|
||||||
height: $weapon-diameter;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
.Button {
|
|
||||||
font-size: $font-regular;
|
|
||||||
padding: ($unit * 1.5) ($unit * 2);
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&.btn-disabled {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-70;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.btn-disabled) {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-50;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $grey-80;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
.Menu {
|
|
||||||
transform-origin: --radix-dropdown-menu-content-transform-origin;
|
|
||||||
background: var(--menu-bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 30vw;
|
|
||||||
max-width: 180px;
|
|
||||||
margin: 0 $unit-2x;
|
|
||||||
z-index: 15;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
min-width: 50vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.MenuLabel {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
padding: $unit * 1.5 $unit * 1.5;
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MenuItem {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: $normal;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(.disabled) {
|
|
||||||
background: var(--menu-bg-item-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:visited {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
background: inherit;
|
|
||||||
color: inherit;
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.profile > div {
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.language {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
padding-right: $unit;
|
|
||||||
|
|
||||||
span {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Switch {
|
|
||||||
$height: 24px;
|
|
||||||
|
|
||||||
background: $grey-60;
|
|
||||||
border-radius: calc($height / 2);
|
|
||||||
border: none;
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: $height;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Thumb {
|
|
||||||
$diameter: 18px;
|
|
||||||
|
|
||||||
background: $grey-100;
|
|
||||||
border-radius: calc($diameter / 2);
|
|
||||||
display: block;
|
|
||||||
height: $diameter;
|
|
||||||
width: $diameter;
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
z-index: 3;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-state='checked'] {
|
|
||||||
background: $grey-100;
|
|
||||||
left: 23px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.left,
|
|
||||||
.right {
|
|
||||||
color: $grey-100;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: $bold;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
top: 6px;
|
|
||||||
left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
top: 6px;
|
|
||||||
right: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: $grey-50;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:visited {
|
|
||||||
color: $grey-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > a,
|
|
||||||
& > span {
|
|
||||||
display: block;
|
|
||||||
padding: 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 10px 12px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
i.tag {
|
|
||||||
background: var(--tag-bg);
|
|
||||||
color: var(--tag-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
$diameter: 32px;
|
|
||||||
border-radius: calc($diameter / 2);
|
|
||||||
height: $diameter;
|
|
||||||
width: $diameter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.MenuGroup {
|
|
||||||
border-bottom: 1px solid var(--menu-separator);
|
|
||||||
|
|
||||||
&:first-child .MenuItem:first-child:hover {
|
|
||||||
border-top-left-radius: 6px;
|
|
||||||
border-top-right-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child .MenuItem:last-child:hover {
|
|
||||||
border-bottom-left-radius: 6px;
|
|
||||||
border-bottom-right-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.ToggleGroup {
|
.group {
|
||||||
$height: 36px;
|
$height: 36px;
|
||||||
|
|
||||||
background-color: var(--toggle-bg);
|
background-color: var(--toggle-bg);
|
||||||
|
|
@ -8,15 +8,28 @@
|
||||||
height: $height;
|
height: $height;
|
||||||
gap: calc($unit / 4);
|
gap: calc($unit / 4);
|
||||||
padding: calc($unit / 2);
|
padding: calc($unit / 2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.ToggleItem {
|
@include breakpoint(phone) {
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
background: var(--toggle-bg);
|
background: var(--toggle-bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
color: var(--input-secondary);
|
color: var(--input-secondary);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
padding: ($unit) $unit * 2;
|
padding-top: $unit;
|
||||||
|
padding-bottom: $unit;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
border-radius: $card-corner;
|
||||||
|
padding-left: $unit-2x;
|
||||||
|
padding-right: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
&.ja {
|
&.ja {
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|
@ -34,32 +47,32 @@
|
||||||
|
|
||||||
&.fire {
|
&.fire {
|
||||||
background: var(--fire-bg);
|
background: var(--fire-bg);
|
||||||
color: var(--fire-text);
|
color: var(--fire-text-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.water {
|
&.water {
|
||||||
background: var(--water-bg);
|
background: var(--water-bg);
|
||||||
color: var(--water-text);
|
color: var(--water-text-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.earth {
|
&.earth {
|
||||||
background: var(--earth-bg);
|
background: var(--earth-bg);
|
||||||
color: var(--earth-text);
|
color: var(--earth-text-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.wind {
|
&.wind {
|
||||||
background: var(--wind-bg);
|
background: var(--wind-bg);
|
||||||
color: var(--wind-text);
|
color: var(--wind-text-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.dark {
|
&.dark {
|
||||||
background: var(--dark-bg);
|
background: var(--dark-bg);
|
||||||
color: var(--dark-text);
|
color: var(--dark-text-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.light {
|
&.light {
|
||||||
background: var(--light-bg);
|
background: var(--light-bg);
|
||||||
color: var(--light-text);
|
color: var(--light-text-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,74 +1,113 @@
|
||||||
import React from 'react'
|
'use client'
|
||||||
import { useRouter } from 'next/router'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { getCookie } from 'cookies-next'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import * as ToggleGroup from '@radix-ui/react-toggle-group'
|
import * as ToggleGroup from '@radix-ui/react-toggle-group'
|
||||||
|
import styles from './index.module.scss'
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentElement: number
|
currentElement: number
|
||||||
sendValue: (value: string) => void
|
sendValue: (value: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ElementToggle = (props: Props) => {
|
const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
|
||||||
const router = useRouter()
|
// Localization
|
||||||
const { t } = useTranslation('common')
|
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
||||||
const locale =
|
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
|
||||||
|
|
||||||
|
const t = useTranslations('common')
|
||||||
|
|
||||||
|
// State: Component
|
||||||
|
const [element, setElement] = useState(currentElement)
|
||||||
|
|
||||||
|
// Methods: Handlers
|
||||||
|
const handleElementChange = (value: string) => {
|
||||||
|
const newElement = parseInt(value)
|
||||||
|
setElement(newElement)
|
||||||
|
sendValue(newElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Rendering
|
||||||
return (
|
return (
|
||||||
<ToggleGroup.Root
|
<ToggleGroup.Root
|
||||||
className="ToggleGroup"
|
className={styles.group}
|
||||||
type="single"
|
type="single"
|
||||||
defaultValue={`${props.currentElement}`}
|
value={`${element}`}
|
||||||
aria-label="Element"
|
aria-label="Element"
|
||||||
onValueChange={props.sendValue}
|
onValueChange={handleElementChange}
|
||||||
>
|
>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="0"
|
value="0"
|
||||||
aria-label="null"
|
aria-label="null"
|
||||||
>
|
>
|
||||||
{t('elements.null')}
|
{t('elements.null')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem wind ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.wind]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="1"
|
value="1"
|
||||||
aria-label="wind"
|
aria-label="wind"
|
||||||
>
|
>
|
||||||
{t('elements.wind')}
|
{t('elements.wind')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem fire ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.fire]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="2"
|
value="2"
|
||||||
aria-label="fire"
|
aria-label="fire"
|
||||||
>
|
>
|
||||||
{t('elements.fire')}
|
{t('elements.fire')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem water ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.water]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="3"
|
value="3"
|
||||||
aria-label="water"
|
aria-label="water"
|
||||||
>
|
>
|
||||||
{t('elements.water')}
|
{t('elements.water')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem earth ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.earth]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="4"
|
value="4"
|
||||||
aria-label="earth"
|
aria-label="earth"
|
||||||
>
|
>
|
||||||
{t('elements.earth')}
|
{t('elements.earth')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem dark ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.dark]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="5"
|
value="5"
|
||||||
aria-label="dark"
|
aria-label="dark"
|
||||||
>
|
>
|
||||||
{t('elements.dark')}
|
{t('elements.dark')}
|
||||||
</ToggleGroup.Item>
|
</ToggleGroup.Item>
|
||||||
<ToggleGroup.Item
|
<ToggleGroup.Item
|
||||||
className={`ToggleItem light ${locale}`}
|
className={classNames({
|
||||||
|
[styles.item]: true,
|
||||||
|
[styles.light]: true,
|
||||||
|
[styles[`${locale}`]]: true,
|
||||||
|
})}
|
||||||
value="6"
|
value="6"
|
||||||
aria-label="light"
|
aria-label="light"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
section.Error {
|
.error {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -9,14 +9,13 @@ section.Error {
|
||||||
height: 60vh;
|
height: 60vh;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.Code {
|
.code {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: $font-tiny;
|
font-size: $font-tiny;
|
||||||
font-weight: $bold;
|
font-weight: $bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button {
|
p {
|
||||||
margin-top: $unit-2x;
|
margin-bottom: $unit-4x;
|
||||||
width: fit-content;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
import Button from '~components/Button'
|
import Button from '~components/common/Button'
|
||||||
import { ResponseStatus } from '~types'
|
import { ResponseStatus } from '~types'
|
||||||
|
|
||||||
import './index.scss'
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: ResponseStatus
|
status: ResponseStatus
|
||||||
|
|
@ -13,7 +13,7 @@ interface Props {
|
||||||
|
|
||||||
const ErrorSection = ({ status }: Props) => {
|
const ErrorSection = ({ status }: Props) => {
|
||||||
// Import translations
|
// Import translations
|
||||||
const { t } = useTranslation('common')
|
const t = useTranslations('common')
|
||||||
|
|
||||||
const [statusText, setStatusText] = useState('')
|
const [statusText, setStatusText] = useState('')
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ const ErrorSection = ({ status }: Props) => {
|
||||||
const errorBody = () => {
|
const errorBody = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="Code">{status.code}</div>
|
<div className={styles.code}>{status.code}</div>
|
||||||
<h1>{t(`errors.${statusText}.title`)}</h1>
|
<h1>{t(`errors.${statusText}.title`)}</h1>
|
||||||
<p>{t(`errors.${statusText}.description`)}</p>
|
<p>{t(`errors.${statusText}.description`)}</p>
|
||||||
</>
|
</>
|
||||||
|
|
@ -32,7 +32,7 @@ const ErrorSection = ({ status }: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="Error">
|
<section className={styles.error}>
|
||||||
{errorBody()}
|
{errorBody()}
|
||||||
{[401, 404].includes(status.code) ? (
|
{[401, 404].includes(status.code) ? (
|
||||||
<Link href="/new">
|
<Link href="/new">
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
.SelectSet {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.SelectTrigger.Left {
|
|
||||||
flex-grow: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.SelectTrigger.Right {
|
|
||||||
flex-grow: 0;
|
|
||||||
text-align: right;
|
|
||||||
min-width: 12rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
.ExtraGrid.Weapons {
|
|
||||||
background: var(--extra-purple-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.42fr 3fr;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: calc($grid-width + 20px);
|
|
||||||
padding: $unit-2x $unit-2x $unit-2x 0;
|
|
||||||
position: relative;
|
|
||||||
left: $unit;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
left: auto;
|
|
||||||
max-width: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
padding: $unit-2x;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > span {
|
|
||||||
color: var(--extra-purple-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ExtraWeapons {
|
|
||||||
display: grid;
|
|
||||||
gap: $unit-3x;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.WeaponUnit .WeaponImage {
|
|
||||||
background: var(--extra-purple-card-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.WeaponUnit .WeaponImage .icon svg {
|
|
||||||
fill: var(--extra-purple-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import WeaponUnit from '~components/WeaponUnit'
|
|
||||||
|
|
||||||
import type { SearchableObject } from '~types'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
grid: GridArray<GridWeapon>
|
|
||||||
editable: boolean
|
|
||||||
found?: boolean
|
|
||||||
offset: number
|
|
||||||
removeWeapon: (id: string) => void
|
|
||||||
updateObject: (object: SearchableObject, position: number) => void
|
|
||||||
updateUncap: (id: string, position: number, uncap: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExtraWeapons = (props: Props) => {
|
|
||||||
const numWeapons: number = 3
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ExtraGrid Weapons">
|
|
||||||
<span>{t('extra_weapons')}</span>
|
|
||||||
<ul id="ExtraWeapons">
|
|
||||||
{Array.from(Array(numWeapons)).map((x, i) => {
|
|
||||||
return (
|
|
||||||
<li key={`grid_unit_${i}`}>
|
|
||||||
<WeaponUnit
|
|
||||||
editable={props.editable}
|
|
||||||
position={props.offset + i}
|
|
||||||
unitType={1}
|
|
||||||
gridWeapon={props.grid[props.offset + i]}
|
|
||||||
removeWeapon={props.removeWeapon}
|
|
||||||
updateObject={props.updateObject}
|
|
||||||
updateUncap={props.updateUncap}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExtraWeapons
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
.FilterBar {
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bar-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit-2x;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-top: 7px; // Line up with HeaderMenu
|
|
||||||
padding: $unit * 2;
|
|
||||||
position: sticky;
|
|
||||||
transition: box-shadow 0.24s ease-in-out;
|
|
||||||
top: $unit * 4;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 996px;
|
|
||||||
min-height: 80px;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
position: static;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Filters {
|
|
||||||
display: flex;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: $unit;
|
|
||||||
width: auto;
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.shadow {
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-regular;
|
|
||||||
font-weight: $normal;
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
select,
|
|
||||||
.SelectTrigger {
|
|
||||||
// background: url("/icons/Arrow.svg"), $grey-90;
|
|
||||||
// background-repeat: no-repeat;
|
|
||||||
// background-position-y: center;
|
|
||||||
// background-position-x: 95%;
|
|
||||||
// background-size: $unit * 1.5;
|
|
||||||
background-color: var(--select-contained-bg);
|
|
||||||
color: $grey-55;
|
|
||||||
font-size: $font-small;
|
|
||||||
margin: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--select-contained-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
width: 100%;
|
|
||||||
max-width: inherit;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.SelectTrigger {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: $font-small;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.UserInfo {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: $unit * 1.5;
|
|
||||||
|
|
||||||
img {
|
|
||||||
$diameter: $unit * 6;
|
|
||||||
border-radius: calc($diameter / 2);
|
|
||||||
height: $diameter;
|
|
||||||
width: $diameter;
|
|
||||||
|
|
||||||
&.gran {
|
|
||||||
background-color: #cee7fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.djeeta {
|
|
||||||
background-color: #ffe1fe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import RaidDropdown from '~components/RaidDropdown'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
import Select from '~components/Select'
|
|
||||||
import SelectItem from '~components/SelectItem'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode
|
|
||||||
scrolled: boolean
|
|
||||||
element?: number
|
|
||||||
raidSlug?: string
|
|
||||||
recency?: number
|
|
||||||
onFilter: ({
|
|
||||||
element,
|
|
||||||
raidSlug,
|
|
||||||
recency,
|
|
||||||
}: {
|
|
||||||
element?: number
|
|
||||||
raidSlug?: string
|
|
||||||
recency?: number
|
|
||||||
}) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilterBar = (props: Props) => {
|
|
||||||
// Set up translation
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
const [recencyOpen, setRecencyOpen] = useState(false)
|
|
||||||
const [elementOpen, setElementOpen] = useState(false)
|
|
||||||
|
|
||||||
// Set up classes object for showing shadow on scroll
|
|
||||||
const classes = classNames({
|
|
||||||
FilterBar: true,
|
|
||||||
shadow: props.scrolled,
|
|
||||||
})
|
|
||||||
|
|
||||||
function openElementSelect() {
|
|
||||||
setElementOpen(!elementOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRecencySelect() {
|
|
||||||
setRecencyOpen(!recencyOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function elementSelectChanged(value: string) {
|
|
||||||
const elementValue = parseInt(value)
|
|
||||||
props.onFilter({ element: elementValue })
|
|
||||||
}
|
|
||||||
|
|
||||||
function recencySelectChanged(value: string) {
|
|
||||||
const recencyValue = parseInt(value)
|
|
||||||
props.onFilter({ recency: recencyValue })
|
|
||||||
}
|
|
||||||
|
|
||||||
function raidSelectChanged(slug?: string) {
|
|
||||||
props.onFilter({ raidSlug: slug })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectChange(name: 'element' | 'recency') {
|
|
||||||
setElementOpen(name === 'element' ? !elementOpen : false)
|
|
||||||
setRecencyOpen(name === 'recency' ? !recencyOpen : false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes}>
|
|
||||||
{props.children}
|
|
||||||
<div className="Filters">
|
|
||||||
<Select
|
|
||||||
value={`${props.element}`}
|
|
||||||
open={elementOpen}
|
|
||||||
onOpenChange={() => onSelectChange('element')}
|
|
||||||
onValueChange={elementSelectChanged}
|
|
||||||
onClick={openElementSelect}
|
|
||||||
>
|
|
||||||
<SelectItem data-element="all" key={-1} value={-1}>
|
|
||||||
{t('elements.full.all')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="null" key={0} value={0}>
|
|
||||||
{t('elements.full.null')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="wind" key={1} value={1}>
|
|
||||||
{t('elements.full.wind')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="fire" key={2} value={2}>
|
|
||||||
{t('elements.full.fire')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="water" key={3} value={3}>
|
|
||||||
{t('elements.full.water')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="earth" key={4} value={4}>
|
|
||||||
{t('elements.full.earth')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="dark" key={5} value={5}>
|
|
||||||
{t('elements.full.dark')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem data-element="light" key={6} value={6}>
|
|
||||||
{t('elements.full.light')}
|
|
||||||
</SelectItem>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<RaidDropdown
|
|
||||||
currentRaid={props.raidSlug}
|
|
||||||
defaultRaid="all"
|
|
||||||
showAllRaidsOption={true}
|
|
||||||
onChange={raidSelectChanged}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={`${props.recency}`}
|
|
||||||
trigger={'All time'}
|
|
||||||
open={recencyOpen}
|
|
||||||
onOpenChange={() => onSelectChange('recency')}
|
|
||||||
onValueChange={recencySelectChanged}
|
|
||||||
onClick={openRecencySelect}
|
|
||||||
>
|
|
||||||
<SelectItem key={-1} value={-1}>
|
|
||||||
{t('recency.all_time')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={86400} value={86400}>
|
|
||||||
{t('recency.last_day')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={604800} value={604800}>
|
|
||||||
{t('recency.last_week')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={2629746} value={2629746}>
|
|
||||||
{t('recency.last_month')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={7889238} value={7889238}>
|
|
||||||
{t('recency.last_3_months')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={15778476} value={15778476}>
|
|
||||||
{t('recency.last_6_months')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem key={31556952} value={31556952}>
|
|
||||||
{t('recency.last_year')}
|
|
||||||
</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FilterBar
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
.GridRep {
|
|
||||||
aspect-ratio: 3/2;
|
|
||||||
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);
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2,
|
|
||||||
.Grid {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Grid .Weapon {
|
|
||||||
box-shadow: inset 0 0 0 1px var(--grid-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
background: inherit;
|
|
||||||
|
|
||||||
.Grid .Weapon {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .Grid {
|
|
||||||
aspect-ratio: 2/1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 3fr; /* 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(--card-bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Mainhand.Weapon {
|
|
||||||
aspect-ratio: 73/153;
|
|
||||||
display: grid;
|
|
||||||
grid-column: 1 / 2; /* spans one column */
|
|
||||||
}
|
|
||||||
|
|
||||||
.GridWeapons {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: calc($unit / 2);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-regular;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
a.user:hover {
|
|
||||||
color: var(--link-text-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Properties,
|
|
||||||
.user {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user,
|
|
||||||
.raid,
|
|
||||||
time {
|
|
||||||
color: $grey-55;
|
|
||||||
font-size: $font-small;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Properties {
|
|
||||||
.full_auto {
|
|
||||||
color: var(--full-auto-label-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.raid {
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: calc($unit / 2);
|
|
||||||
|
|
||||||
&.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
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/Button'
|
|
||||||
|
|
||||||
import SaveIcon from '~public/icons/Save.svg'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
shortcode: string
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
raid: Raid
|
|
||||||
grid: GridWeapon[]
|
|
||||||
user?: User
|
|
||||||
fullAuto: boolean
|
|
||||||
favorited: boolean
|
|
||||||
createdAt: Date
|
|
||||||
displayUser?: boolean | false
|
|
||||||
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({
|
|
||||||
raid: true,
|
|
||||||
empty: !props.raid,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userClass = classNames({
|
|
||||||
user: true,
|
|
||||||
empty: !props.user,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newWeapons = Array(numWeapons)
|
|
||||||
const gridWeapons = Array(numWeapons)
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(props.grid)) {
|
|
||||||
if (value.position == -1) setMainhand(value.object)
|
|
||||||
else if (!value.mainhand && value.position != null) {
|
|
||||||
newWeapons[value.position] = value.object
|
|
||||||
gridWeapons[value.position] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 linkedAttribution = () => (
|
|
||||||
<Link href={`/${props.user ? props.user.username : '#'}`}>
|
|
||||||
<span className={userClass}>
|
|
||||||
{userImage()}
|
|
||||||
{props.user ? props.user.username : t('no_user')}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
const unlinkedAttribution = () => (
|
|
||||||
<div className={userClass}>
|
|
||||||
{userImage()}
|
|
||||||
{props.user ? props.user.username : t('no_user')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const details = (
|
|
||||||
<div className="Details">
|
|
||||||
<h2 className={titleClass}>{props.name ? props.name : t('no_title')}</h2>
|
|
||||||
<div className="bottom">
|
|
||||||
<div className="Properties">
|
|
||||||
<span className={raidClass}>
|
|
||||||
{props.raid ? props.raid.name[locale] : t('no_raid')}
|
|
||||||
</span>
|
|
||||||
{props.fullAuto ? (
|
|
||||||
<span className="full_auto">
|
|
||||||
{` · ${t('party.details.labels.full_auto')}`}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
|
|
||||||
{formatTimeAgo(props.createdAt, locale)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const detailsWithUsername = (
|
|
||||||
<div className="Details">
|
|
||||||
<div className="top">
|
|
||||||
<div className="info">
|
|
||||||
<h2 className={titleClass}>
|
|
||||||
{props.name ? props.name : t('no_title')}
|
|
||||||
</h2>
|
|
||||||
<div className="Properties">
|
|
||||||
<span className={raidClass}>
|
|
||||||
{props.raid ? props.raid.name[locale] : t('no_raid')}
|
|
||||||
</span>
|
|
||||||
{props.fullAuto ? (
|
|
||||||
<span className="full_auto">
|
|
||||||
{` · ${t('party.details.labels.full_auto')}`}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{account.authorized &&
|
|
||||||
((props.user && account.user && account.user.id !== props.user.id) ||
|
|
||||||
!props.user) ? (
|
|
||||||
<Link href="#">
|
|
||||||
<Button
|
|
||||||
className="Save"
|
|
||||||
leftAccessoryIcon={<SaveIcon className="stroke" />}
|
|
||||||
active={props.favorited}
|
|
||||||
contained={true}
|
|
||||||
buttonSize="small"
|
|
||||||
onClick={sendSaveData}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="bottom">
|
|
||||||
{props.user ? linkedAttribution() : unlinkedAttribution()}
|
|
||||||
|
|
||||||
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
|
|
||||||
{formatTimeAgo(props.createdAt, locale)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/p/${props.shortcode}`}>
|
|
||||||
<a className="GridRep">
|
|
||||||
{props.displayUser ? detailsWithUsername : details}
|
|
||||||
<div className="Grid">
|
|
||||||
<div className="Mainhand Weapon">{generateMainhandImage()}</div>
|
|
||||||
|
|
||||||
<ul className="GridWeapons">
|
|
||||||
{Array.from(Array(numWeapons)).map((x, i) => {
|
|
||||||
return (
|
|
||||||
<li key={`${props.shortcode}-${i}`} className="Grid Weapon">
|
|
||||||
{generateGridImage(i)}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GridRep
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import classNames from 'classnames'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import './index.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const GridRepCollection = (props: Props) => {
|
|
||||||
const classes = classNames({
|
|
||||||
GridRepCollection: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div className={classes}>{props.children}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GridRepCollection
|
|
||||||
|
|
@ -1,4 +1,32 @@
|
||||||
#Header {
|
.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;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin-bottom: $unit;
|
margin-bottom: $unit;
|
||||||
|
|
@ -22,7 +50,7 @@
|
||||||
background: var(--placeholder-bg);
|
background: var(--placeholder-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#DropdownWrapper {
|
.dropdownWrapper {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-bottom: $unit;
|
padding-bottom: $unit;
|
||||||
|
|
||||||
|
|
@ -32,8 +60,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
// padding-right: $unit-4x;
|
|
||||||
|
|
||||||
.Button {
|
.Button {
|
||||||
background: var(--button-bg-hover);
|
background: var(--button-bg-hover);
|
||||||
color: var(--button-text-hover);
|
color: var(--button-text-hover);
|
||||||
|
|
@ -1,86 +1,58 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
'use client'
|
||||||
import { subscribe, useSnapshot } from 'valtio'
|
import React, { useState } from 'react'
|
||||||
import { setCookie, deleteCookie } from 'cookies-next'
|
import { deleteCookie, getCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/router'
|
import { useTranslations } from 'next-intl'
|
||||||
import { Trans, useTranslation } from 'next-i18next'
|
import { useRouter } from '~/i18n/navigation'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import clonedeep from 'lodash.clonedeep'
|
import clonedeep from 'lodash.clonedeep'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import api from '~utils/api'
|
|
||||||
import { accountState, initialAccountState } from '~utils/accountState'
|
import { accountState, initialAccountState } from '~utils/accountState'
|
||||||
import { appState, initialAppState } from '~utils/appState'
|
import { appState, initialAppState } from '~utils/appState'
|
||||||
import { getLocalId } from '~utils/localId'
|
|
||||||
import { retrieveLocaleCookies } from '~utils/retrieveCookies'
|
|
||||||
import { setEditKey, storeEditKey } from '~utils/userToken'
|
|
||||||
|
|
||||||
|
import Alert from '~components/common/Alert'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
} from '~components/common/DropdownMenuContent'
|
||||||
} from '~components/DropdownMenuContent'
|
import DropdownMenuGroup from '~components/common/DropdownMenuGroup'
|
||||||
import LoginModal from '~components/LoginModal'
|
import DropdownMenuLabel from '~components/common/DropdownMenuLabel'
|
||||||
import SignupModal from '~components/SignupModal'
|
import DropdownMenuItem from '~components/common/DropdownMenuItem'
|
||||||
import AccountModal from '~components/AccountModal'
|
import LanguageSwitch from '~components/LanguageSwitch'
|
||||||
import Toast from '~components/Toast'
|
import LoginModal from '~components/auth/LoginModal'
|
||||||
import Button from '~components/Button'
|
import SignupModal from '~components/auth/SignupModal'
|
||||||
import Tooltip from '~components/Tooltip'
|
import AccountModal from '~components/auth/AccountModal'
|
||||||
import * as Switch from '@radix-ui/react-switch'
|
import Button from '~components/common/Button'
|
||||||
|
import Tooltip from '~components/common/Tooltip'
|
||||||
|
|
||||||
import ArrowIcon from '~public/icons/Arrow.svg'
|
import BahamutIcon from '~public/icons/Bahamut.svg'
|
||||||
import LinkIcon from '~public/icons/Link.svg'
|
import ChevronIcon from '~public/icons/Chevron.svg'
|
||||||
import MenuIcon from '~public/icons/Menu.svg'
|
import MenuIcon from '~public/icons/Menu.svg'
|
||||||
import RemixIcon from '~public/icons/Remix.svg'
|
|
||||||
import PlusIcon from '~public/icons/Add.svg'
|
import PlusIcon from '~public/icons/Add.svg'
|
||||||
import SaveIcon from '~public/icons/Save.svg'
|
|
||||||
|
|
||||||
import './index.scss'
|
import styles from './index.module.scss'
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
// Localization
|
// Localization
|
||||||
const { t } = useTranslation('common')
|
const t = useTranslations('common')
|
||||||
|
|
||||||
// Router
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const locale =
|
|
||||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
// Locale
|
||||||
const localeData = retrieveLocaleCookies()
|
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
||||||
|
|
||||||
|
// Subscribe to account state changes
|
||||||
|
const accountSnap = useSnapshot(accountState)
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [copyToastOpen, setCopyToastOpen] = useState(false)
|
const [alertOpen, setAlertOpen] = useState(false)
|
||||||
const [remixToastOpen, setRemixToastOpen] = useState(false)
|
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
||||||
const [signupModalOpen, setSignupModalOpen] = useState(false)
|
const [signupModalOpen, setSignupModalOpen] = useState(false)
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
||||||
const [leftMenuOpen, setLeftMenuOpen] = useState(false)
|
const [leftMenuOpen, setLeftMenuOpen] = useState(false)
|
||||||
const [rightMenuOpen, setRightMenuOpen] = useState(false)
|
const [rightMenuOpen, setRightMenuOpen] = useState(false)
|
||||||
const [languageChecked, setLanguageChecked] = useState(false)
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [originalName, setOriginalName] = useState('')
|
|
||||||
|
|
||||||
// Snapshots
|
|
||||||
const { account } = useSnapshot(accountState)
|
|
||||||
const { party: partySnapshot } = useSnapshot(appState)
|
|
||||||
|
|
||||||
// Subscribe to app state to listen for party name and
|
|
||||||
// unsubscribe when component is unmounted
|
|
||||||
const unsubscribe = subscribe(appState, () => {
|
|
||||||
const newName =
|
|
||||||
appState.party && appState.party.name ? appState.party.name : ''
|
|
||||||
setName(newName)
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => () => unsubscribe(), [])
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
setLanguageChecked(localeData === 'ja' ? true : false)
|
|
||||||
}, [localeData])
|
|
||||||
|
|
||||||
// Methods: Event handlers (Buttons)
|
// Methods: Event handlers (Buttons)
|
||||||
function handleLeftMenuButtonClicked() {
|
function handleLeftMenuButtonClicked() {
|
||||||
|
|
@ -108,24 +80,6 @@ const Header = () => {
|
||||||
setRightMenuOpen(false)
|
setRightMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods: Event handlers (Copy toast)
|
|
||||||
function handleCopyToastOpenChanged(open: boolean) {
|
|
||||||
setCopyToastOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCopyToastCloseClicked() {
|
|
||||||
setCopyToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Event handlers (Remix toasts)
|
|
||||||
function handleRemixToastOpenChanged(open: boolean) {
|
|
||||||
setRemixToastOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemixToastCloseClicked() {
|
|
||||||
setRemixToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Actions
|
// Methods: Actions
|
||||||
function handleNewTeam(event: React.MouseEvent) {
|
function handleNewTeam(event: React.MouseEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -133,32 +87,6 @@ const Header = () => {
|
||||||
closeRightMenu()
|
closeRightMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeLanguage(value: boolean) {
|
|
||||||
const language = value ? 'ja' : 'en'
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 120)
|
|
||||||
|
|
||||||
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
|
|
||||||
router.push(router.asPath, undefined, { locale: language })
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard() {
|
|
||||||
const path = router.asPath.split('/')[1]
|
|
||||||
|
|
||||||
if (path === 'p') {
|
|
||||||
const el = document.createElement('input')
|
|
||||||
el.value = window.location.href
|
|
||||||
el.id = 'url-input'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
|
|
||||||
el.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
el.remove()
|
|
||||||
|
|
||||||
setCopyToastOpen(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
// Close menu
|
// Close menu
|
||||||
closeRightMenu()
|
closeRightMenu()
|
||||||
|
|
@ -173,7 +101,7 @@ const Header = () => {
|
||||||
if (key !== 'language') accountState[key] = resetState[key]
|
if (key !== 'language') accountState[key] = resetState[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
router.reload()
|
router.refresh()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,90 +116,11 @@ const Header = () => {
|
||||||
router.push('/new')
|
router.push('/new')
|
||||||
}
|
}
|
||||||
|
|
||||||
function remixTeam() {
|
// Methods: Rendering
|
||||||
setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
|
|
||||||
|
|
||||||
if (partySnapshot.shortcode) {
|
|
||||||
const body = getLocalId()
|
|
||||||
api
|
|
||||||
.remix({ shortcode: partySnapshot.shortcode, body: body })
|
|
||||||
.then((response) => {
|
|
||||||
const remix = response.data.party
|
|
||||||
|
|
||||||
// Store the edit key in local storage
|
|
||||||
if (remix.edit_key) {
|
|
||||||
storeEditKey(remix.id, remix.edit_key)
|
|
||||||
setEditKey(remix.id, remix.user)
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/p/${remix.shortcode}`)
|
|
||||||
setRemixToastOpen(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFavorite() {
|
|
||||||
if (partySnapshot.favorited) unsaveFavorite()
|
|
||||||
else saveFavorite()
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFavorite() {
|
|
||||||
if (partySnapshot.id)
|
|
||||||
api.saveTeam({ id: partySnapshot.id }).then((response) => {
|
|
||||||
if (response.status == 201) appState.party.favorited = true
|
|
||||||
})
|
|
||||||
else console.error('Failed to save team: No party ID')
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsaveFavorite() {
|
|
||||||
if (partySnapshot.id)
|
|
||||||
api.unsaveTeam({ id: partySnapshot.id }).then((response) => {
|
|
||||||
if (response.status == 200) appState.party.favorited = false
|
|
||||||
})
|
|
||||||
else console.error('Failed to unsave team: No party ID')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Elements
|
|
||||||
const pageTitle = () => {
|
|
||||||
let title = ''
|
|
||||||
let hasAccessory = false
|
|
||||||
|
|
||||||
const path = router.asPath.split('/')[1]
|
|
||||||
if (path === 'p') {
|
|
||||||
hasAccessory = true
|
|
||||||
if (appState.party && appState.party.name) {
|
|
||||||
title = appState.party.name
|
|
||||||
} else {
|
|
||||||
title = t('no_title')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
title = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return title !== '' ? (
|
|
||||||
<Tooltip content={t('tooltips.copy_url')}>
|
|
||||||
<Button
|
|
||||||
blended={true}
|
|
||||||
rightAccessoryIcon={
|
|
||||||
path === 'p' && hasAccessory ? (
|
|
||||||
<LinkIcon className="stroke" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
text={title}
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileImage = () => {
|
const profileImage = () => {
|
||||||
let image
|
const user = accountSnap.account.user
|
||||||
|
if (accountSnap.account.authorized && user) {
|
||||||
const user = accountState.account.user
|
return (
|
||||||
if (accountState.account.authorized && user) {
|
|
||||||
image = (
|
|
||||||
<img
|
<img
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
className={`profile ${user.avatar.element}`}
|
className={`profile ${user.avatar.element}`}
|
||||||
|
|
@ -281,9 +130,9 @@ const Header = () => {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
image = (
|
return (
|
||||||
<img
|
<img
|
||||||
alt={t('no_user')}
|
alt={t('header.anonymous')}
|
||||||
className={`profile anonymous`}
|
className={`profile anonymous`}
|
||||||
srcSet={`/profile/npc.png,
|
srcSet={`/profile/npc.png,
|
||||||
/profile/npc@2x.png 2x`}
|
/profile/npc@2x.png 2x`}
|
||||||
|
|
@ -291,327 +140,266 @@ const Header = () => {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return image
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendering: Buttons
|
// Rendering: Buttons
|
||||||
const saveButton = () => {
|
const newButton = (
|
||||||
return (
|
<Tooltip content={t('tooltips.new')}>
|
||||||
<Tooltip content={t('tooltips.save')}>
|
<Button
|
||||||
<Button
|
leftAccessoryIcon={<PlusIcon />}
|
||||||
leftAccessoryIcon={<SaveIcon />}
|
className="New"
|
||||||
className={classNames({
|
blended={true}
|
||||||
Save: true,
|
text={t('buttons.new')}
|
||||||
Saved: partySnapshot.favorited,
|
onClick={newTeam}
|
||||||
})}
|
|
||||||
blended={true}
|
|
||||||
text={
|
|
||||||
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
|
|
||||||
}
|
|
||||||
onClick={toggleFavorite}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const newButton = () => {
|
|
||||||
return (
|
|
||||||
<Tooltip content={t('tooltips.new')}>
|
|
||||||
<Button
|
|
||||||
leftAccessoryIcon={<PlusIcon />}
|
|
||||||
className="New"
|
|
||||||
blended={true}
|
|
||||||
text={t('buttons.new')}
|
|
||||||
onClick={newTeam}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remixButton = () => {
|
|
||||||
return (
|
|
||||||
<Tooltip content={t('tooltips.remix')}>
|
|
||||||
<Button
|
|
||||||
leftAccessoryIcon={<RemixIcon />}
|
|
||||||
className="Remix"
|
|
||||||
blended={true}
|
|
||||||
text={t('buttons.remix')}
|
|
||||||
onClick={remixTeam}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Toasts
|
|
||||||
const urlCopyToast = () => {
|
|
||||||
return (
|
|
||||||
<Toast
|
|
||||||
altText={t('toasts.copied')}
|
|
||||||
open={copyToastOpen}
|
|
||||||
duration={2400}
|
|
||||||
type="foreground"
|
|
||||||
content={t('toasts.copied')}
|
|
||||||
onOpenChange={handleCopyToastOpenChanged}
|
|
||||||
onCloseClick={handleCopyToastCloseClicked}
|
|
||||||
/>
|
/>
|
||||||
)
|
</Tooltip>
|
||||||
}
|
)
|
||||||
|
|
||||||
const remixToast = () => {
|
|
||||||
return (
|
|
||||||
<Toast
|
|
||||||
altText={t('toasts.remixed', { title: originalName })}
|
|
||||||
open={remixToastOpen}
|
|
||||||
duration={2400}
|
|
||||||
type="foreground"
|
|
||||||
content={
|
|
||||||
<Trans i18nKey="toasts.remixed">
|
|
||||||
You remixed <strong>{{ title: originalName }}</strong>
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
onOpenChange={handleRemixToastOpenChanged}
|
|
||||||
onCloseClick={handleRemixToastCloseClicked}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Modals
|
// Rendering: Modals
|
||||||
const settingsModal = () => {
|
const logoutConfirmationAlert = (
|
||||||
const user = accountState.account.user
|
<Alert
|
||||||
|
message={t('alert.confirm_logout')}
|
||||||
|
open={alertOpen}
|
||||||
|
primaryActionText="Log out"
|
||||||
|
primaryAction={logout}
|
||||||
|
cancelActionText="Nevermind"
|
||||||
|
cancelAction={() => setAlertOpen(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
if (user) {
|
const settingsModal = (
|
||||||
return (
|
<>
|
||||||
|
{accountSnap.account.user && (
|
||||||
<AccountModal
|
<AccountModal
|
||||||
open={settingsModalOpen}
|
open={settingsModalOpen}
|
||||||
username={user.username}
|
username={accountSnap.account.user.username}
|
||||||
picture={user.avatar.picture}
|
picture={accountSnap.account.user.avatar.picture}
|
||||||
gender={user.gender}
|
gender={accountSnap.account.user.gender}
|
||||||
language={user.language}
|
language={accountSnap.account.user.language}
|
||||||
theme={user.theme}
|
theme={accountSnap.account.user.theme}
|
||||||
|
role={accountSnap.account.user.role}
|
||||||
|
bahamutMode={
|
||||||
|
accountSnap.account.user.role === 9
|
||||||
|
? accountSnap.account.user.bahamut
|
||||||
|
: false
|
||||||
|
}
|
||||||
onOpenChange={setSettingsModalOpen}
|
onOpenChange={setSettingsModalOpen}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
}
|
</>
|
||||||
}
|
)
|
||||||
|
|
||||||
const loginModal = () => {
|
const loginModal = (
|
||||||
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||||
}
|
)
|
||||||
|
|
||||||
const signupModal = () => {
|
const signupModal = (
|
||||||
return (
|
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
||||||
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Compositing
|
// Rendering: Compositing
|
||||||
const left = () => {
|
const authorizedLeftItems = (
|
||||||
return (
|
<>
|
||||||
<section>
|
{accountSnap.account.user && (
|
||||||
<div id="DropdownWrapper">
|
<>
|
||||||
<DropdownMenu
|
<DropdownMenuGroup>
|
||||||
open={leftMenuOpen}
|
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||||
onOpenChange={handleLeftMenuOpenChange}
|
<Link
|
||||||
>
|
href={`/${accountSnap.account.user.username}` || ''}
|
||||||
<DropdownMenuTrigger asChild>
|
>
|
||||||
<Button
|
<span>{t('menu.profile')}</span>
|
||||||
leftAccessoryIcon={<MenuIcon />}
|
</Link>
|
||||||
className={classNames({ Active: leftMenuOpen })}
|
</DropdownMenuItem>
|
||||||
blended={true}
|
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||||
onClick={handleLeftMenuButtonClicked}
|
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
||||||
/>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuContent className="Left">
|
</>
|
||||||
{leftMenuItems()}
|
)}
|
||||||
</DropdownMenuContent>
|
</>
|
||||||
</DropdownMenu>
|
)
|
||||||
</div>
|
const leftMenuItems = (
|
||||||
{!appState.errorCode ? pageTitle() : ''}
|
<>
|
||||||
</section>
|
{accountSnap.account.authorized &&
|
||||||
)
|
accountSnap.account.user &&
|
||||||
}
|
authorizedLeftItems}
|
||||||
|
|
||||||
const right = () => {
|
<DropdownMenuGroup>
|
||||||
return (
|
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||||
<section>
|
<Link href="/teams">{t('menu.teams')}</Link>
|
||||||
{router.route === '/p/[party]' &&
|
</DropdownMenuItem>
|
||||||
account.user &&
|
<DropdownMenuItem>
|
||||||
(!partySnapshot.user || partySnapshot.user.id !== account.user.id) &&
|
<div>
|
||||||
!appState.errorCode
|
<span>{t('menu.guides')}</span>
|
||||||
? saveButton()
|
<i className="tag">{t('coming_soon')}</i>
|
||||||
: ''}
|
</div>
|
||||||
{router.route === '/p/[party]' && !appState.errorCode
|
</DropdownMenuItem>
|
||||||
? remixButton()
|
</DropdownMenuGroup>
|
||||||
: ''}
|
<DropdownMenuGroup>
|
||||||
{newButton()}
|
<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
|
<DropdownMenu
|
||||||
open={rightMenuOpen}
|
open={leftMenuOpen}
|
||||||
onOpenChange={handleRightMenuOpenChange}
|
onOpenChange={handleLeftMenuOpenChange}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={classNames({ Active: rightMenuOpen })}
|
active={leftMenuOpen}
|
||||||
leftAccessoryIcon={profileImage()}
|
|
||||||
rightAccessoryIcon={<ArrowIcon />}
|
|
||||||
rightAccessoryClassName="Arrow"
|
|
||||||
onClick={handleRightMenuButtonClicked}
|
|
||||||
blended={true}
|
blended={true}
|
||||||
|
leftAccessoryIcon={<MenuIcon />}
|
||||||
|
onClick={handleLeftMenuButtonClicked}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="Right">
|
<DropdownMenuContent className="Left">
|
||||||
{rightMenuItems()}
|
{leftMenuItems}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</section>
|
</div>
|
||||||
)
|
</section>
|
||||||
}
|
)
|
||||||
|
|
||||||
const leftMenuItems = () => {
|
const authorizedRightItems = (
|
||||||
return (
|
<>
|
||||||
<>
|
{accountSnap.account.user && (
|
||||||
{accountState.account.authorized && accountState.account.user ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<Link
|
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<span>{t('menu.profile')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<Link href="/teams">{t('menu.teams')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="MenuItem">
|
|
||||||
<div>
|
|
||||||
<span>{t('menu.guides')}</span>
|
|
||||||
<i className="tag">{t('coming_soon')}</i>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/about' : '/about'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/updates' : '/updates'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rightMenuItems = () => {
|
|
||||||
let items
|
|
||||||
|
|
||||||
const account = accountState.account
|
|
||||||
if (account.authorized && account.user) {
|
|
||||||
items = (
|
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="MenuLabel">
|
<DropdownMenuLabel>
|
||||||
{account.user ? `@${account.user.username}` : t('no_user')}
|
{`@${accountSnap.account.user.username}`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem className="MenuItem" onClick={closeRightMenu}>
|
<DropdownMenuItem onClick={closeRightMenu}>
|
||||||
<Link href={`/${account.user.username}` || ''} passHref>
|
<Link
|
||||||
|
href={`/${accountSnap.account.user.username}` || ''}
|
||||||
|
>
|
||||||
<span>{t('menu.profile')}</span>
|
<span>{t('menu.profile')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="MenuItem"
|
className="MenuItem"
|
||||||
onClick={() => setSettingsModalOpen(true)}
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
>
|
>
|
||||||
<span>{t('menu.settings')}</span>
|
<span>{t('menu.settings')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="MenuItem" onClick={logout}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setAlertOpen(true)}
|
||||||
|
destructive={true}
|
||||||
|
>
|
||||||
<span>{t('menu.logout')}</span>
|
<span>{t('menu.logout')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</>
|
</>
|
||||||
)
|
)}
|
||||||
} else {
|
</>
|
||||||
items = (
|
)
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem className="MenuItem language">
|
|
||||||
<span>{t('menu.language')}</span>
|
|
||||||
<Switch.Root
|
|
||||||
className="Switch"
|
|
||||||
onCheckedChange={changeLanguage}
|
|
||||||
checked={languageChecked}
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="Thumb" />
|
|
||||||
<span className="left">JP</span>
|
|
||||||
<span className="right">EN</span>
|
|
||||||
</Switch.Root>
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
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 = (
|
||||||
|
<>
|
||||||
|
{accountSnap.account.authorized && accountSnap.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 (
|
return (
|
||||||
<nav id="Header">
|
<>
|
||||||
{left()}
|
{accountSnap.account.user?.bahamut && (
|
||||||
{right()}
|
<div className={styles.bahamut}>
|
||||||
{urlCopyToast()}
|
<BahamutIcon />
|
||||||
{remixToast()}
|
<p>Bahamut Mode is active</p>
|
||||||
{settingsModal()}
|
</div>
|
||||||
{loginModal()}
|
)}
|
||||||
{signupModal()}
|
<nav className={styles.header}>
|
||||||
</nav>
|
{left}
|
||||||
|
{right}
|
||||||
|
{logoutConfirmationAlert}
|
||||||
|
{settingsModal}
|
||||||
|
{loginModal}
|
||||||
|
{signupModal}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
div[data-radix-popper-content-wrapper] {
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.HovercardContent {
|
|
||||||
animation: scaleIn $duration-zoom ease-out;
|
|
||||||
transform-origin: var(--radix-hover-card-content-transform-origin);
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
max-height: 30vh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding: $unit-2x;
|
|
||||||
width: 300px;
|
|
||||||
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: calc($unit / 2);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit * 2;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
flex-grow: 1;
|
|
||||||
font-size: $font-medium;
|
|
||||||
line-height: 1.2;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: auto;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subInfo {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit * 2;
|
|
||||||
|
|
||||||
.icons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-grow: 1;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.UncapIndicator {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
h5 {
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
opacity: 0.7;
|
|
||||||
|
|
||||||
&.wind {
|
|
||||||
color: $wind-bg-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fire {
|
|
||||||
color: $fire-bg-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.water {
|
|
||||||
color: $water-bg-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.earth {
|
|
||||||
color: $earth-bg-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
color: $dark-bg-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.light {
|
|
||||||
color: $light-bg-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.Button {
|
|
||||||
display: block;
|
|
||||||
padding: $unit * 1.5;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
components/HovercardHeader/index.module.scss
Normal file
59
components/HovercardHeader/index.module.scss
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc($unit / 2);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit * 2;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: $font-medium;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.perpetuity {
|
||||||
|
position: absolute;
|
||||||
|
background-image: url('/icons/perpetuity/filled.svg');
|
||||||
|
background-size: $unit-3x $unit-3x;
|
||||||
|
z-index: 20;
|
||||||
|
top: $unit-half * -1;
|
||||||
|
right: $unit-3x;
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subInfo {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
.icons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-grow: 0;
|
||||||
|
gap: $unit-half;
|
||||||
|
|
||||||
|
.proficiencies {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
components/HovercardHeader/index.tsx
Normal file
193
components/HovercardHeader/index.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
'use client'
|
||||||
|
import { getCookie } from 'cookies-next'
|
||||||
|
|
||||||
|
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
|
||||||
|
object: Character | Summon | Weapon
|
||||||
|
type: 'character' | 'summon' | 'weapon'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
||||||
|
const Proficiency = [
|
||||||
|
'none',
|
||||||
|
'sword',
|
||||||
|
'dagger',
|
||||||
|
'axe',
|
||||||
|
'spear',
|
||||||
|
'bow',
|
||||||
|
'staff',
|
||||||
|
'fist',
|
||||||
|
'harp',
|
||||||
|
'gun',
|
||||||
|
'katana',
|
||||||
|
]
|
||||||
|
|
||||||
|
const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
|
||||||
|
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
||||||
|
|
||||||
|
const overlay = () => {
|
||||||
|
if (type === 'character') {
|
||||||
|
const gridCharacter = gridObject as GridCharacter
|
||||||
|
if (gridCharacter.perpetuity) return <i className={styles.perpetuity} />
|
||||||
|
} else if (type === 'summon') {
|
||||||
|
const gridSummon = gridObject as GridSummon
|
||||||
|
if (gridSummon.quick_summon) return <i className={styles.quickSummon} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterImage = () => {
|
||||||
|
const gridCharacter = gridObject as GridCharacter
|
||||||
|
const character = object as Character
|
||||||
|
|
||||||
|
// Change the image based on the uncap level
|
||||||
|
let suffix = '01'
|
||||||
|
if (gridCharacter.uncap_level == 6) suffix = '04'
|
||||||
|
else if (gridCharacter.uncap_level == 5) suffix = '03'
|
||||||
|
else if (gridCharacter.uncap_level > 2) suffix = '02'
|
||||||
|
|
||||||
|
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_${suffix}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
const summonImage = () => {
|
||||||
|
const summon = object as Summon
|
||||||
|
const gridSummon = gridObject as GridSummon
|
||||||
|
|
||||||
|
const upgradedSummons = [
|
||||||
|
'2040094000',
|
||||||
|
'2040100000',
|
||||||
|
'2040080000',
|
||||||
|
'2040098000',
|
||||||
|
'2040090000',
|
||||||
|
'2040084000',
|
||||||
|
'2040003000',
|
||||||
|
'2040056000',
|
||||||
|
]
|
||||||
|
|
||||||
|
let suffix = ''
|
||||||
|
if (
|
||||||
|
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
|
||||||
|
gridSummon.uncap_level == 5
|
||||||
|
) {
|
||||||
|
suffix = '_02'
|
||||||
|
} else if (
|
||||||
|
gridSummon.object.uncap.transcendence &&
|
||||||
|
gridSummon.transcendence_step > 0
|
||||||
|
) {
|
||||||
|
suffix = '_03'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the correct source for the summon
|
||||||
|
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
const weaponImage = () => {
|
||||||
|
const gridWeapon = gridObject as GridWeapon
|
||||||
|
const weapon = object as Weapon
|
||||||
|
|
||||||
|
if (gridWeapon.object.element == 0 && gridWeapon.element)
|
||||||
|
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
|
||||||
|
else
|
||||||
|
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'character':
|
||||||
|
return characterImage()
|
||||||
|
case 'summon':
|
||||||
|
return summonImage()
|
||||||
|
case 'weapon':
|
||||||
|
return weaponImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<h4>{object.name[locale]}</h4>
|
||||||
|
<div className={styles.image}>
|
||||||
|
{overlay()}
|
||||||
|
<img alt={object.name[locale]} src={image()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.subInfo}>
|
||||||
|
{proficiency()}
|
||||||
|
<UncapIndicator
|
||||||
|
className="hovercard"
|
||||||
|
type={type}
|
||||||
|
ulb={object.uncap.ulb || false}
|
||||||
|
flb={object.uncap.flb || false}
|
||||||
|
transcendenceStage={
|
||||||
|
'transcendence_step' in gridObject
|
||||||
|
? gridObject.transcendence_step
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
special={'special' in object ? object.special : false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HovercardHeader
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue