Compare commits

...

97 commits

Author SHA1 Message Date
e3b95bc6ba
Add hasInstallScript property to package.json 2025-11-27 22:12:23 -08:00
1f8de7ee30
Fix Railway build errors by marking dynamic routes (#437)
## Summary
- Fixes Railway deployment build failures caused by dynamic server usage
errors
- Marks routes that use runtime features as `force-dynamic` to prevent
static generation attempts
- Creates proper error pages to handle 404/500 scenarios

## Problem
The build was failing with "Dynamic server usage" errors because Next.js
was trying to statically generate pages that use runtime features like:
- `cookies()` for authentication
- `searchParams` for filtering
- Dynamic data fetching that requires request-time context

## Solution
Added `export const dynamic = 'force-dynamic'` to:

### API Routes
- `/api/jobs/route.ts` - uses searchParams
- `/api/jobs/skills/route.ts` - uses cookies via fetchFromApi
- `/api/version/route.ts` - uses cookies via fetchFromApi
- `/api/raids/groups/route.ts` - uses cookies via fetchFromApi
- `/api/parties/route.ts` - uses searchParams and cookies
- `/api/parties/[shortcode]/route.ts` - uses cookies
- `/api/parties/[shortcode]/remix/route.ts` - uses cookies

### Page Components
- `/app/[locale]/teams/page.tsx` - uses searchParams
- `/app/[locale]/new/page.tsx` - fetches dynamic data
- `/app/[locale]/saved/page.tsx` - uses cookies and searchParams
- Additional pages to avoid useContext errors during static generation

### Error Handling
- Created `/pages/_error.tsx` - Simple error page without i18n
complexity
- Created `/app/not-found.tsx` - App Router 404 page

## Test plan
- [x] Build completes successfully locally with `npm run build`
- [ ] Deploy to Railway staging environment
- [ ] Verify all dynamic routes work correctly
- [ ] Check error pages display properly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 02:41:03 -07:00
e4b7f0c356
Fix TypeScript errors for production build (#436)
## Summary
Fixed multiple TypeScript errors that were preventing the production
build from completing on Railway.

## Changes Made

### Nullable Type Fixes
- Fixed `searchParams.toString()` calls with optional chaining (`?.`)
and fallback values
- Fixed `pathname` nullable access in UpdateToastClient
- Added fallbacks for undefined values in translation interpolations

### Type Consistency Fixes
- Fixed recency parameter handling (string from URL, converted to number
internally)
- Removed duplicate local interface definitions for Party and User types
- Fixed Party type mismatches by using global type definitions

### API Route Error Handling
- Fixed error type checking in catch blocks for login/signup routes
- Added proper type guards for axios error objects

### Component Props Fixes
- Fixed RadixSelect.Trigger by removing invalid placeholder prop
- Fixed Toast and Tooltip components by using Omit to exclude
conflicting content type
- Added missing onAdvancedFilter prop to FilterBar components
- Fixed PartyFooter props with required parameters

## Test Plan
- [x] Fixed all TypeScript compilation errors locally
- [ ] Production build should complete successfully on Railway
- [ ] All affected components should function correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 01:47:10 -07:00
02676fd7d4
Fix ESLint errors causing Railway deployment failure (#435)
## Summary
- Fixed unescaped apostrophes in JSX text that were causing ESLint
errors
- These errors were preventing the production build from completing on
Railway

## Changes
- `app/[locale]/error.tsx`: Escaped apostrophe in "couldn't"
- `app/[locale]/not-found.tsx`: Escaped apostrophes in "you're" and
"couldn't"
- `app/[locale]/unauthorized/page.tsx`: Escaped apostrophe in "don't"

## Test plan
- [x] ESLint errors resolved locally
- [ ] Railway deployment succeeds after merge

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-09-03 23:23:27 -07:00
d2bf37a40e
Remove images that were accidentally committed (#434) 2025-09-03 23:16:30 -07:00
778a1c70bd
Fix authentication state hydration mismatch (#433)
## Summary
- Fixed avatar showing anonymous for several seconds on page load
- Eliminated hydration mismatch for authentication state
- Header now shows correct user state immediately

## Root Cause
AccountStateInitializer was running client-side in useEffect AFTER
hydration, causing:
1. Server renders anonymous state
2. Client hydrates with anonymous state
3. useEffect runs and updates state (causing the flash)

## Solution
- Read auth cookies server-side in layout.tsx
- Pass initial auth data as props to AccountStateInitializer
- Initialize Valtio state synchronously before first render
- Client-side cookie reading only as fallback

## Changes
- Added server-side cookie parsing in layout.tsx
- Modified AccountStateInitializer to accept initial auth data props
- Made Header component reactive with useSnapshot from Valtio
- State initialization happens synchronously, preventing the flash

## Test plan
- [x] Avatar renders correctly on first load
- [x] No anonymous avatar flash when logged in
- [x] Login/logout still works properly
- [x] State updates are reactive in the header

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 17:34:34 -07:00
73395efee8
Migrate about pages to App Router (#432)
## Summary
- Migrated about, updates, and roadmap pages from Pages Router to App
Router
- Fixed profile page data loading and display
- Created API route handlers for proxying backend calls
- Fixed translation format issues with next-intl

## Changes
- Created new App Router pages under `/app/[locale]/`
- Fixed translation interpolation from `{{variable}}` to `{variable}`
format
- Added API routes for characters, raids, summons, and weapons
- Fixed infinite recursion in ChangelogUnit by renaming fetch function
- Converted from useTranslation to useTranslations hook

## Test plan
- [x] About page loads and displays correctly
- [x] Updates page fetches and displays changelog data
- [x] Roadmap page renders without errors
- [x] Profile page shows user teams correctly
- [x] All translations render properly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 17:20:16 -07:00
fa23c13db1
Modernize Link components to Next.js 13+ patterns (#431)
## Summary
- Removed legacy behavior from Link components
- Fixed onClick warnings with next-intl Link wrapper
- Fixed tab switching on party page
- Fixed JobSkillItem router undefined error

## Changes
- Removed `legacyBehavior` prop and nested `<a>` tags from all Link
components
- Updated GridTabsCompact to use next-intl's Link wrapper correctly
- Fixed PartyPageClient tab switching by mapping string values to
GridType enum
- Removed broken locale assignment code in JobSkillItem

## Test plan
- [x] No more console warnings about onClick and legacyBehavior
- [x] Tab switching works correctly on party page
- [x] No router undefined errors in JobSkillItem
- [x] All navigation links work as expected

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 17:07:09 -07:00
3d67622353
Fix i18n migration to next-intl (#430)
## Summary
- Fixed translation key format compatibility with next-intl
- Fixed pluralization format from i18next to next-intl format
- Fixed dynamic translation key error handling
- Updated server components to match API response structure
- Fixed useSearchParams import location

## Changes
- Changed pluralization from `{{count}} items` to `{count} items` format
- Added proper error handling for missing translation keys
- Fixed import paths for next-intl hooks
- Fixed PartyPageClient trying to set non-existent appState.parties

## Test plan
- [x] Verified translations render correctly
- [x] Tested pluralization works with different counts
- [x] Confirmed no console errors about missing translations
- [x] Tested party page functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 16:25:59 -07:00
b1472fd35d
Fix package manager errors (#429)
- **Use bounded caching via unstable_cache, add Axios client with
timeout/keepAlive for server-side requests, and dedupe preview API route
by removing duplicate handler**
- **Remove Dockerfile and .dockerignore; keep PR focused on caching and
HTTP client fixes using npm (no Docker)**
2025-08-31 12:30:52 -07:00
426645813e
Fix intermittent crash: bounded caching + HTTP timeouts/keepAlive + preview route dedupe (#428)
## Summary
- Fixes periodic production crashes (undici ECONNREFUSED ::1) by
bounding server cache size/lifetime and hardening server HTTP client.

### Root cause
- React server cache (cache(...)) held axios responses indefinitely
across many parameter combinations, causing slow memory growth until the
Next.js app router worker was OOM-killed. The main server then failed
IPC to the worker (ECONNREFUSED ::1:<port>).

### Changes
- `app/lib/data.ts`: Replace unbounded cache(...) with unstable_cache
and explicit keys; TTLs: 60s for teams/detail/favorites/user, 300s for
meta (jobs/skills/accessories/raids/version).
- `app/lib/api-utils.ts`: Add shared Axios instance with 15s timeout and
keepAlive http/https agents; apply to GET/POST/PUT/DELETE helpers.
- `pages/api/preview/[shortcode].ts`: Remove duplicate handler to dedupe
route; retain the .tsx variant using `NEXT_PUBLIC_SIERO_API_URL`.

### Notes
- Build currently has pre-existing app/pages route duplication errors;
out of scope here but unrelated to this fix.
- Ensure `NEXT_PUBLIC_SIERO_API_URL` and `NEXT_PUBLIC_SIERO_OAUTH_URL`
are set on Railway.

### Risk/impact
- Low risk; behavior is unchanged aside from bounded caching and
resilient HTTP.
- Cache TTLs can be tuned later if needed.

### Test plan
- Verify saved/teams/user pages load and revalidate after TTL.
- Validate API routes still proxy correctly; timeouts occur after ~15s
for hung upstreams.
- Monitor memory over several days; expect stable usage without steady
growth.
2025-08-31 12:16:42 -07:00
0be7be8612
Fix saving characters (#427)
Saving characters was buggy because we added a key to the
`GridCharacter` response. They added to the backend but didn't show up
on the frontend without a refresh. Now we properly unwrap things and
newly added characters display instantly again.
2025-02-25 10:53:45 -05:00
156f4222d7
Modify users#info endpoint to send username (#426) 2025-02-13 05:42:00 -08:00
0e7aeed5d6
CSS fixes for summon grid and optional fix for character hovercard (#425) 2025-02-13 01:12:06 -08:00
a02a6c70aa
Jedmund/image embeds 2 (#424)
## Component Refactors:
- Updated `CharacterHovercard` to improve over mastery and awakening
section logic.
- Refactored `CharacterModal` to streamline state management (rings,
awakening, perpetuity) and object preparation.
- Adjusted `CharacterUnit` for consistent over mastery handling.
- Simplified `AwakeningSelectWithInput` to use awakening slug values and
improve error handling.
- Updated `RingSelect` to refine ring value syncing and index logic.
- Modified `Party` and `PartyHead` to ensure consistent over mastery
processing and proper preview URL construction.
- Updated `WeaponModal` to align awakening value handling with the new
slug-based approach.

## Styling and Configuration:
- Improved grid layout and styling in the `WeaponRep` SCSS module.
- Updated `next.config.js` rewrite rules to support new preview and
character routes.
- Added a new API endpoint (`pages/api/preview/[shortcode].tsx`) for
fetching party preview images.

## Type Definitions:
- Refined types in `types/GridCharacter.d.ts` and `types/index.d.ts` to
reflect updated structures for rings, over mastery, and awakening.
2025-02-09 22:54:15 -08:00
eff96e5a37
More work on image embeds (#423)
* Writes redirect for preview images so we're not embedding the API URL
in user-facing pages
* Adds the NextJs API page that actually serves the image
* Use a more straightforward URL construction method
* Add the og:image:width and og:image:height
2025-01-20 03:51:21 -08:00
2628d1745b
Use party.id to query embed image instead of shortcode (#422)
The API doesn't understand shortcodes as indexes
2025-01-18 09:30:59 -08:00
0dc03d44f3
Adds og:image for showing the page preview (#421) 2025-01-18 09:08:11 -08:00
4a30dbbf9f
Add weapons and items from April and early May (#420)
* Chloe and Kolulu
* Fire and Dark Omega Rebirth weapons
* LuciZero pendulums

---------

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-05-06 16:49:57 -07:00
4bc211c240
Fix renamed variables (#419)
Silly bug!

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-05-06 14:51:49 -07:00
2160a57a20
Further GridRep and Collection fixes (#417)
* z-index issues
* Some issues with GridRep (but there are still issues)
* Added a message when no teams are found for the current set of filters

---------

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-04-23 05:11:23 -07:00
4a38168593
Collection fixes (#416)
This fixes some issues that cropped up in the last PR

---------

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-04-22 23:57:49 -07:00
2eaaf1baae
Added updates from the past 2 weeks (#415)
* March 2024 Legfest
* April 2024  Flash Gala
* Celestial Weapons
* April Fools character
* Cardinal Saints character and uncap

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-04-21 03:59:10 -07:00
fc616aab01
Break collection pages into hooks (#414)
This refactors the collection pages (teams, saved and profiles) into a
bunch of hooks that handle various chunks of functionality. This way,
the actual "pages" have significantly less logic and significantly less
repeated code.

* **useFavorites** handles favoriting teams
* **useFilterState** handles the URL query filters
* **usePaginationState** simply holds data pertaining to pagination
* **useFetchTeams** handles fetching and parsing team data from the
server
* **useTeamFilter** pulls all other states together and handles some
logic that is closest to the page

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-04-21 00:46:04 -07:00
4dc2279d68
Used the wrong key (#413)
character.uncap.transcendence doesn't exist!

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-03-25 06:01:51 -04:00
ecbfd3ae7f
Game updates: 2024-03-25 (#412)
* Adds new raids and drop items
* Adds support for showing transcendence and subauras in search results

---------

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
2024-03-25 05:55:19 -04:00
Justin Edmund
de8e3b322c Remove unused dependency 2024-03-13 19:46:37 -07:00
Justin Edmund
3cac1d03cc Fix display of telumas in WeaponUnit 2024-03-13 19:46:30 -07:00
Justin Edmund
c43e36e525 Fix search modal filtering
Why were we scoping this to only job_skills?
2024-03-13 19:46:13 -07:00
cfa78dccc3
Add year selector to updates page (#410)
The Updates page was getting really long which meant a humongous
request. This splits it up by year.
2024-03-11 07:21:39 -07:00
5824b3ccea
Added new items from anniversary update (#409)
* Transcended Primals
* Awakened Grands
* Uncapped weapons
2024-03-11 06:38:17 -07:00
eaef607dc3 2024-02 Legfest
* Sandalphon (Grand)
* Sabrina
* Richard
2024-02-29 00:58:33 -05:00
a3815d2866
Hotfix SCSS nesting bug (#408) 2024-02-21 23:29:08 -05:00
31745b17de
February 2024 updates and bug fixes (#407)
### New content
* Adds Onmyoji
* Adds Dark Rapture Zero
* Adds Exo Aristarchus

### Bug fixes
* Fixed a bug that prevented filtering job skills by category
* Fixed a bug that prevented infinite scroll in search modals
2024-02-21 23:18:53 -05:00
0f03ca5e27
Added items from 2024/02 Flash Gala and the February uncap (#406)
- Cidala (Valentine)
- Nehan (Valentine)
- Europa uncap
2024-02-18 17:00:15 -05:00
3ee2a1ac47
2024/01 Legend Festival (#405)
Adds items from 2024/01 Legend Festival and final set of Ultimate
Mastery skills
2024-01-30 22:40:48 -08:00
5a5457f10d
Add support for weapon transcendence images (#404)
Also adds new content from 2024/01 Flash Gala and My Hero Academia
collab.
2024-01-25 02:51:04 -08:00
74077a0501
Hotfix code replace error (#403) 2024-01-15 14:22:13 -08:00
3b6cc5ba65
Add support for weapon transcendence (#402) 2024-01-15 14:16:49 -08:00
acf9773f38
Add Lucilius profile picture for salem (#401) 2024-01-08 04:47:13 -08:00
cac2613e9e
Add note to latest update (#400) 2024-01-08 04:32:30 -08:00
bc3f716f8c
Updates next-usequerystate to nuqs (#399) 2024-01-08 04:20:45 -08:00
9ad2b64e2d
2024/01 Update (#398)
* Adds Celestial Weapons and final Evoker uncaps
* Fixes a bug that prevented logged out users from creating new parties
from the Character or Summon tabs
2024-01-08 04:07:19 -08:00
06c7192f25
2023/12 Legfest (#396)
* Dracosius (Payila)
* Pillardriver (Uriel)
* Buddysaurus Rex (Tyra)
* Triple Zero
* Tuna and Salmun
* Mother's Mad Spine
* T. Axe
2023-12-30 20:29:02 -08:00
e93343018f
Fix text colors for tags (#395)
We need to use -element-text-contrast
2023-12-30 16:04:21 -08:00
b665d005d5
Hotfix issues regarding Draconic Weapons (#394)
* Properly send draconic key 3 to API
* Show key images over Draconic Weapons Providence
* Show key images in hovercards
2023-12-30 07:28:33 -08:00
02fe61df88
Add Zeta (Grand) and Lowain (#393) 2023-12-28 03:06:40 -08:00
c58957f98e
Hotfix: Providence key not in appState (#392) 2023-12-26 04:43:49 -08:00
8f9f7d7a07
Add notes to latest update (#391) 2023-12-26 04:28:53 -08:00
cb2efb07a5
Adding content from late-November to mid-December (#390)
## Characters
* Illnott (Holiday)
* Yuni (Holiday)
* Noa (Holiday)
* Shalem (Holiday)

## New Weapons
* Blockbuster
* Nox Meteorum
* Grim Evidence
* Trap Knight's Helm
* Ornamental Paddle
* Bab-Bell Rendezvous
* Exo Maitrah Karuna

## Weapon unaps
* The World weapons
* Daggerpeak
* Dawn Rising
2023-12-26 04:19:57 -08:00
3347d47eeb
Logic updates for Draconic Weapons Providence (#389) 2023-12-26 03:21:39 -08:00
5449297a48
Update page for last few updates (#388)
* November Flash Gala
* Revans Mk II
* Draconic Weapons Providence (not completely implemented yet)
* More Ultimate Mastery skills
2023-11-18 19:45:29 -08:00
348277de9b
(Hotfix) Don't send extra property to backend explicitly (#387) 2023-11-05 14:28:05 -08:00
57197c99e8
Adds items from the 2023-10 Flash Gala (and others) (#386)
I was slacking so this is three updates in one
2023-10-18 14:15:19 -07:00
6135b5bed4
Fix ability for non-owners to change team visibility (#385)
This fixes #384 

* Hides "Change party visibility" button when a user is viewing an
unlisted team they did not create
* Prevents updating team at all if the `editable` flag is not set (which
might break something else for anonymous teams... we'll see)
2023-10-11 11:25:08 +09:00
7dd5d6988a Add redirect for hensei-transfer CORS 2023-09-21 19:02:10 -07:00
ce3001132f Rework GridRep button display 2023-09-15 11:17:15 -07:00
27079a5a72 Add updates for 2023-09 Flash Gala 2023-09-15 07:42:43 -07:00
9a9e6a12f3 Small fixes 2023-09-09 17:44:49 -07:00
c715dc2593 Fix build error 2023-09-09 02:31:55 -07:00
ab4b563754
Implement rudimentary Bahamut Mode (#381)
Bahamut Mode lets me make sure people aren't doing naughty things behind
closed doors.
2023-09-09 02:29:30 -07:00
9bb5e721ff Update with new weapons and manatura 2023-09-08 21:12:56 -07:00
b50ea1fa31
(Hotfix) Popover and hovercard fixes (#379)
* Fix job accessory popover, so shields and manatura can be selected
again
* Don't show AX skill section in weapon hovercard if no AX skill is set
* Center uncap indicator under item image and fix hovercard header
layout
* Fix a bug that prevented all ring bonuses from displaying in hovercard
* Fix transcendence_step being set to 0 when updating a character's
masteries
* Fix weapon modal so you can set AX skills on weapons with rupee or exp
gain
* Ensure job accessory and transcendence popovers open/close properly
2023-09-01 16:13:39 -07:00
14ad468737 Update with summon uncaps from 8/22 2023-08-31 09:54:37 -07:00
b92b2fad1d Fix looping over developer notes 2023-08-30 23:17:26 -07:00
d8136d90c3
Release notes 1.2.0 (#375)
* Updated the updates page with a combination of feature releases from
the past 6 months, combined into version 1.2.0.
* Also added new items from 2023-08 Legfest
2023-08-30 23:13:39 -07:00
3ef77cec0c
(Hotfix) Don't always show scrollbars on Editor (#373)
Bad CSS made it so that scrollbars always showed up in descriptions and
the editor. I didn't see it because I am usually working on a Mac with
display scrollbars off. Anyway, it's fixed now.
2023-08-26 16:31:41 -07:00
9e6c9a2108
Implement party visibility (#369)
Parties can now be set to be private or unlisted. Private parties cannot
be shared with anyone while Unlisted parties can be seen by those with
the link.

We implemented a dialog to change visibility, notices to let users know
if a party isn't public, and icons on the GridRep so users can see at a
glance which of their parties has different visibility on their profile.

![CleanShot 2023-08-25 at 15 50
10@2x](https://github.com/jedmund/hensei-web/assets/383021/488b7fe2-497a-48f3-982a-d603c0a34539)

![CleanShot 2023-08-25 at 15 49
45@2x](https://github.com/jedmund/hensei-web/assets/383021/675523f6-d158-4019-8c1a-cf87b48501f9)

![CleanShot 2023-08-25 at 15 50
49@2x](https://github.com/jedmund/hensei-web/assets/383021/419a3b06-f083-4c9e-b4fb-ea70669513fd)
2023-08-25 15:51:28 -07:00
aabd7de207
Implement experimental GridRep (#368)
https://github.com/jedmund/hensei-web/assets/383021/d18f68f4-a14a-45a8-81b1-1addb5bd6ed1

This adds an experimental GridRep feature for testing. There are
indicator bars underneath the grid preview on desktop that when hovered
over, shows the user a peek into the other views of the team.

I have qualms about this but I'm pushing it to production so that myself
and others can play with it more.
2023-08-23 23:42:52 -07:00
62b957034f
Search views (#367)
Add support for switching between viewing newly added items and recently
used items in search for weapons and summons
2023-08-23 02:49:27 -07:00
8877f3cfeb Merge branch 'main' of github.com:jedmund/hensei-web 2023-08-22 20:02:08 -07:00
24d871e04a Fix build error from stray GridRep 2023-08-22 20:02:05 -07:00
58087d9f5b
Further refine load transitions (#366)
* Wires up GridRep to accept a `loading` prop
* Only transitions when replacing
* Fade out and fade in transition different durations
2023-08-22 19:59:49 -07:00
4c7732d3cb Merge branch 'main' of github.com:jedmund/hensei-web 2023-08-22 11:51:43 -07:00
2d1af335c3
Implement load transitions and fix resetting filters (#365)
This PR implements:
* Fade-in transitions when cells load in, making navigation and loading
appear less janky.
* When scrolling, skeleton reps show up before the actual ones load in.
* Resetting filters will also reset any set inclusions or exclusions
2023-08-22 01:29:48 -07:00
74b41230e7 Implement loading reps 2023-08-22 01:27:42 -07:00
bc8b4c200c Resetting inclusion/exclusion fields with filters 2023-08-22 01:25:21 -07:00
737300c80a Update ref location 2023-08-22 01:25:02 -07:00
78202a49df Create LoadingRep
This is a skeleton rep that can be used when loading
2023-08-22 01:24:50 -07:00
10cb78c11f Move GridRep/Collection and add fade in 2023-08-22 01:24:36 -07:00
6dd2579e6e
Fix server side error when no filters cookie present (#364)
This was clown town, but when the user _doesn't_ have any filters, the
teams page would not load at all.
2023-08-21 20:14:39 -07:00
a4e4328329
Add support for including/excluding items from team filtering (#363)
This PR adds support for including/excluding specific items from team
filtering. Users can use the filter modal to only show teams that
include specific items, only show teams that _don't_ include specific
items, or combine the two to create a very powerful filter.
2023-08-21 20:01:11 -07:00
99c7eb73c1 Explicitly set on buttons, inputs and textareas 2023-08-20 04:08:28 -07:00
51eb937e0a Implement custom font in styles 2023-08-20 04:07:52 -07:00
e9ead2c7b3 Implement custom font
This implements our custom font at the <body> tag level using useIsomorphicLayoutEffect
2023-08-20 04:06:59 -07:00
67b7e3eb73 Add legacyBehavior flag to Links with nested a
We'll fix this later
2023-08-20 04:06:26 -07:00
e37495072d Update to Nextjs 13 to use next/font 2023-08-20 04:04:51 -07:00
736ab4d175
Fix typo (#362) 2023-08-16 04:38:17 -07:00
29c9a700c1
Add updates from 2023/08/16 Flash Gala (and more) (#361)
I'm not writing notes for this, sorry
2023-08-16 04:08:43 -07:00
e5e946aee1
Add items from the July 2023 Legend Festival (#360)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles

* Update transcendence components to work with CSS modules (#350)

* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.

* Add toolbar localizations

* Allow translation of Heading icons

* Show localized placeholder for team name

* Add placeholder extension

* Add placeholder to party description

* Ensure name modification works right

Needed a null check? for some reason?

* Small fix for some modals on mobile

This fixes the slide up animation and the end point so that modals are actually visible on mobile. Ones that scroll still don't work great.

* Fix TableField components on mobile

* Put viewport meta tag in _app

* Some fixes for scrollable dialogs on mobile

This is 100% not going to scale to devices that are not my iPhone 14 Pro Max, but I can't get env variables working in CSS and something is better than nothing for right now.

* Disable tab pages

* Update with items from July 2023 Flash Gala

### Weapons
- Beach Grynoth
- Splash Howl

### Characters
- Vaseraga (Summer)
- Enyo (Summer)

* Correct version

* Add uncap event

* Add units from the July 2023 Legfest

Characters:
* Fediel (Summer)
* Aliza (Summer)
* Claudia and Dorothy (Summer)
* Yurius (Summer)

Weapons
* Fediel Float
* Sunset Blaze
* Shine and Silence
* Tentacular Javelin
* King's Thruster
* Konbu Dashi

Summons:
* Cerberus (Summer)
2023-07-30 21:34:05 -07:00
d0b1b7fde2
Add items from July 2023 Flash Gala (#356)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles

* Update transcendence components to work with CSS modules (#350)

* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.

* Add toolbar localizations

* Allow translation of Heading icons

* Show localized placeholder for team name

* Add placeholder extension

* Add placeholder to party description

* Ensure name modification works right

Needed a null check? for some reason?

* Small fix for some modals on mobile

This fixes the slide up animation and the end point so that modals are actually visible on mobile. Ones that scroll still don't work great.

* Fix TableField components on mobile

* Put viewport meta tag in _app

* Some fixes for scrollable dialogs on mobile

This is 100% not going to scale to devices that are not my iPhone 14 Pro Max, but I can't get env variables working in CSS and something is better than nothing for right now.

* Disable tab pages

* Update with items from July 2023 Flash Gala

### Weapons
- Beach Grynoth
- Splash Howl

### Characters
- Vaseraga (Summer)
- Enyo (Summer)

* Correct version

* Add uncap event
2023-07-16 01:43:40 -07:00
19c852c13b
Hotfix to disable page tabs (#354)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles

* Update transcendence components to work with CSS modules (#350)

* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.

* Add toolbar localizations

* Allow translation of Heading icons

* Show localized placeholder for team name

* Add placeholder extension

* Add placeholder to party description

* Ensure name modification works right

Needed a null check? for some reason?

* Small fix for some modals on mobile

This fixes the slide up animation and the end point so that modals are actually visible on mobile. Ones that scroll still don't work great.

* Fix TableField components on mobile

* Put viewport meta tag in _app

* Some fixes for scrollable dialogs on mobile

This is 100% not going to scale to devices that are not my iPhone 14 Pro Max, but I can't get env variables working in CSS and something is better than nothing for right now.

* Disable tab pages
2023-07-06 22:28:41 -07:00
f7f723b3f4
Tactical mobile fixes (#352)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles

* Update transcendence components to work with CSS modules (#350)

* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.

* Add toolbar localizations

* Allow translation of Heading icons

* Show localized placeholder for team name

* Add placeholder extension

* Add placeholder to party description

* Ensure name modification works right

Needed a null check? for some reason?

* Small fix for some modals on mobile

This fixes the slide up animation and the end point so that modals are actually visible on mobile. Ones that scroll still don't work great.

* Fix TableField components on mobile

* Put viewport meta tag in _app

* Some fixes for scrollable dialogs on mobile

This is 100% not going to scale to devices that are not my iPhone 14 Pro Max, but I can't get env variables working in CSS and something is better than nothing for right now.
2023-07-06 19:23:40 -07:00
65bc7100c4
Deploy transcendence fixes (#351)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles

* Update transcendence components to work with CSS modules (#350)

* Update transcendence components to use CSS modules

* Fix summon transcendence

Summon transcendence was doing something wonky. This adapts the updateUncap endpoint method to make it a little bit clearer whats going on.

* Add toolbar localizations

* Allow translation of Heading icons

* Show localized placeholder for team name

* Add placeholder extension

* Add placeholder to party description

* Ensure name modification works right

Needed a null check? for some reason?
2023-07-06 17:09:21 -07:00
a19e2055b9
Fix some loose styles (#347)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding

* Fix styles
2023-07-06 03:34:14 -07:00
209f6b733f
Merge conflict bug (#346)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path

* Remove duplicate binding
2023-07-06 03:08:51 -07:00
9f87d712b9
Case sensitivity sucks (#345)
* Rich text editor and support for tagging objects (#340)

* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors

* Override peer dependencies for tiptap mentions

They haven't fixed the suggestion plugin, so we have to use a beta version

* Fix background-color on CharacterRep

* Tiptap updates (#343)

* Reinstantiate editor on changes

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

* Fix import

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

* Added several Tiptap components

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

* Add colors for highlights

* Add ToolbarButton component

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

* Add extensions and implement ToolbarButton

* Remove unused code

* Use party prop and add keys

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

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

* Component cleanup

* Always use props.party

* Ensure content gets reset when edits are committed

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

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

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

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

* Fix build errors

* Fix icon path
2023-07-06 03:06:48 -07:00
366 changed files with 18779 additions and 6834 deletions

5
.aidigestignore Normal file
View file

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

5
.env.local Normal file
View 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

6
.gitignore vendored
View file

@ -59,6 +59,8 @@ public/images/updates*
public/images/guidebooks*
public/images/raids*
public/images/gacha*
public/images/previews*
public/image/profiles*
# Typescript v1 declaration files
typings/
@ -86,3 +88,7 @@ typings/
# DS_Store
.DS_Store
*.tsbuildinfo
codebase.md
# PRDs
prd/

2
.mise.toml Normal file
View file

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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

View file

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

28
CLAUDE.md Normal file
View 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

View 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

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

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

View 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
View 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&apos;t automatically recover from.</p>
<div className="error-message">
<p>{error.message || 'An unexpected error occurred'}</p>
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
</div>
<div className="error-actions">
<button onClick={reset} className="button primary">
Try again
</button>
<Link href="/teams" className="button secondary">
Browse teams
</Link>
</div>
</div>
</div>
)
}

View file

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

View 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

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

View 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&apos;re looking for couldn&apos;t be found</p>
<div className="error-actions">
<Link href="/new" className="button primary">
Create a new party
</Link>
<Link href="/teams" className="button secondary">
Browse teams
</Link>
</div>
</div>
</div>
)
}

View file

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

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

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

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

View 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

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

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

View 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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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&apos;re looking for doesn&apos;t exist.</p>
<div className="error-actions">
<a href="/new" className="button primary">
Create a new party
</a>
<a href="/teams" className="button secondary">
Browse teams
</a>
</div>
</div>
</div>
)
}

77
app/styles/error.scss Normal file
View 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);
}
}
}
}

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

View file

@ -1,6 +1,7 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
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'
@ -12,12 +13,10 @@ interface Props {
}
const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
// Router and localization
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// Localization
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
const { t } = useTranslation('common')
const t = useTranslations('common')
// State: Component
const [element, setElement] = useState(currentElement)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
import { useRouter } from 'next/router'
'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
@ -27,9 +29,7 @@ const Proficiency = [
]
const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
const overlay = () => {
if (type === 'character') {
@ -76,7 +76,7 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
) {
suffix = '_02'
} else if (
gridSummon.object.uncap.xlb &&
gridSummon.object.uncap.transcendence &&
gridSummon.transcendence_step > 0
) {
suffix = '_03'
@ -107,6 +107,61 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
}
}
const summonProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
</div>
)
const weaponProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && !Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency]}
size="small"
/>
)}
</div>
)
const characterProficiency = (
<div
className={classNames({
[styles.icons]: true,
})}
>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[0]]}
size="small"
/>
)}
{'proficiency' in object &&
Array.isArray(object.proficiency) &&
object.proficiency.length > 1 && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[1]]}
size="small"
/>
)}
</div>
)
function proficiency() {
switch (type) {
case 'character':
return characterProficiency
case 'summon':
return summonProficiency
case 'weapon':
return weaponProficiency
}
}
return (
<header className={styles.root}>
<div className={styles.title}>
@ -117,21 +172,9 @@ const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
</div>
</div>
<div className={styles.subInfo}>
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} />
{'proficiency' in object && Array.isArray(object.proficiency) && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency[0]]} />
)}
{'proficiency' in object && !Array.isArray(object.proficiency) && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency]} />
)}
{'proficiency' in object &&
Array.isArray(object.proficiency) &&
object.proficiency.length > 1 && (
<WeaponLabelIcon labelType={Proficiency[object.proficiency[1]]} />
)}
</div>
{proficiency()}
<UncapIndicator
className="hovercard"
type={type}
ulb={object.uncap.ulb || false}
flb={object.uncap.flb || false}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import React from 'react'
import { Trans, useTranslation } from 'next-i18next'
import { useTranslations } from 'next-intl'
import classNames from 'classnames'
import LinkItem from '../LinkItem'
@ -12,8 +12,8 @@ import styles from './index.module.scss'
interface Props {}
const AboutPage: React.FC<Props> = (props: Props) => {
const { t: common } = useTranslation('common')
const { t: about } = useTranslation('about')
const common = useTranslations('common')
const about = useTranslations('about')
const classes = classNames(styles.about, 'PageContent')
@ -22,17 +22,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<h1>{common('about.segmented_control.about')}</h1>
<section>
<h2>
<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>
{about.rich('about.subtitle', {
gameLink: (chunks) => (
<a
href="https://game.granbluefantasy.jp"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
)
})}
</h2>
<p>{about('about.explanation.0')}</p>
<p>{about('about.explanation.1')}</p>
@ -54,53 +54,52 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<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>
{about.rich('about.credits.maintainer', {
link: (chunks) => (
<a
href="https://twitter.com/jedmund"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
)
})}
</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>
{about.rich('about.credits.assistance', {
link1: (chunks) => (
<a
href="https://twitter.com/lalalalinna"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
),
link2: (chunks) => (
<a
href="https://twitter.com/tarngerine"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
)
})}
</p>
<p>
<Trans i18nKey="about:about.credits.support">
Many thanks also go to everyone in{' '}
<a
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
target="_blank"
rel="noreferrer"
>
Fireplace
</a>{' '}
and the granblue-tools Discord for all of their help with with bug
testing, feature requests, and moral support. (P.S. We&apos;re
recruiting!)
</Trans>
{about.rich('about.credits.support', {
link: (chunks) => (
<a
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
)
})}
</p>
</section>
@ -126,17 +125,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<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>
{about.rich('about.license.license', {
link: (chunks) => (
<a
href="https://choosealicense.com/licenses/agpl-3.0/"
target="_blank"
rel="noreferrer"
>
{chunks}
</a>
)
})}
</p>
<p>{about('about.license.explanation')}</p>
</section>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@
flex-direction: column;
gap: $unit-2x;
min-width: 20vw;
max-width: 30vw;
max-width: 40vw;
padding: $unit * 4;
@include breakpoint(tablet) {

View file

@ -46,6 +46,10 @@
flex-grow: 1;
}
&.no-shrink {
flex-shrink: 0;
}
&.blended {
background: transparent;
}
@ -304,6 +308,15 @@
}
}
&.notice {
background-color: var(--notice-button-bg);
color: var(--notice-button-text);
&:hover {
background-color: var(--notice-button-bg-hover);
}
}
&.destructive {
background: $error;
color: white;
@ -399,7 +412,7 @@
&.wind {
background: var(--wind-bg);
color: var(--wind-text);
color: var(--wind-text-contrast);
&:hover {
background: var(--wind-bg-hover);
@ -409,7 +422,7 @@
&.fire {
background: var(--fire-bg);
color: var(--fire-text);
color: var(--fire-text-contrast);
&:hover {
background: var(--fire-bg-hover);
@ -419,7 +432,7 @@
&.water {
background: var(--water-bg);
color: var(--water-text);
color: var(--water-text-contrast);
&:hover {
background: var(--water-bg-hover);
@ -429,7 +442,7 @@
&.earth {
background: var(--earth-bg);
color: var(--earth-text);
color: var(--earth-text-contrast);
&:hover {
background: var(--earth-bg-hover);
@ -439,7 +452,7 @@
&.dark {
background: var(--dark-bg);
color: var(--dark-text);
color: var(--dark-text-contrast);
&:hover {
background: var(--dark-bg-hover);
@ -449,7 +462,7 @@
&.light {
background: var(--light-bg);
color: var(--light-text);
color: var(--light-text-contrast);
&:hover {
background: var(--light-bg-hover);

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