Compare commits

..

118 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
1806269877
Deploy tiptap updates (#344)
* 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
2023-07-06 02:55:59 -07:00
4c949d9206
July 2023 Feature Release: Rich text editor and support for tagging objects (#340) (#341)
* Preliminary work around making an Element type

* Disabled Youtube code for now

* Clean description with DOMPurify

* Update GranblueElement with slug

* Add new api endpoint for searching all resources

* Add new variables and themes

* Remove fixed height on html tag for now

* Update README.md

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

* Add no results string

* Add tiptap and associated packages

* Update .gitignore

* Update components that use character images

* Add Editor component

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

* Add mention components

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

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

* Implements Editor in edit team and team footer

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

* Remove min-width on tokens

* Add rudimentary conversion for old descriptions

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

* Add support for displaying jobs in MentionList

* Handle numbers and value=0 better

* Keep description reactive

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

* Send locale to api with search query

* Delete getLocale.tsx

We didn't actually use this

* Fix build errors
2023-07-05 21:51:30 -07:00
702566e2ed
(Hotfix) (Temporary) nuclear option for raid population (#339)
* Another attempt to fix RaidCombobox loading

* Final nuclear option of getting raids to populate

No matter what I do, raids won't populate from state specifically in production. I will have to investigate this more, but for now we are going with the nuclear option of passing raids down from the context object we get from SSR through all components into RaidCombobox
2023-07-04 02:20:48 -07:00
e2effa0d66
Another attempt to fix RaidCombobox loading (#338) 2023-07-04 02:05:55 -07:00
a820e5ad5f
(Hotfix) Fixes some minor bugs (#337)
* Properly set and call raidGroups from state

Does this fix our bug? We'll find out!

* Fix EditPartyModal confirmation on new teams

EditPartyModal was popping a confirmation alert on teams that had no data in them when exiting details

* Add themed placeholder colors for raids

We don't have images for a lot of the new raid images. Here, we create themed placeholder colors and use those instead of images. The images can't react to the users theme as easily, so this is a better solution for now.

* Fix RaidCombobox not switching to raid's section

The RaidCombobox should always open to the section that contains the current raid, or the middle section if there is no raid selected. There was some spaghetti code, but this should fix it.
2023-07-04 01:53:51 -07:00
9c3c36e81b
Migrate to CSS modules (#335) (#336)
* Modify next.js to re-enable CSS modules

* Rename all files and fix imports

* Renaming index.scss files to index.module.scss
* Changing `import from` to `import styles from`

* Fix dialog styles

* Fix button styles

* Fix dropdown styles

* Fix overlay styles

* Fix segmented control styles

* Fix auth modals

* Fix input styles

* Fix grid rep styles

* Extract language switch component

* Fix party header styles

* Fix header styles

* Fix filter bar styles

* Fix token styles

* Remove tag style from globals

This moved to DropdownMenuItem as thats the only place it's currently used

* Add some shades of purple

* Fix tooltip styles

* Fix star styles

* Fix unit styles

* Fix grid styles

* Fix job styles

* Combine Input and CharLimitedFieldset

We fixed the input component and added a character counter to it, so we don't need a separate CharLimitedFieldSet anymore.

The input component has been simplified to *just* be an input component, so it no longer displays an error. We will make a new component for error handling and labeling. It will probably be an improvement on our custom Fieldset somehow.

* (WIP) Update auth modals for new Input

These rely on error handling and so will need to be fixed more in the future

* Clean up button component some more

Here we add a floating prop for displaying buttons on top of things, like in units. We also renamed contained to bound to match other components and added an icon size.

* Fix styles for perpetuity icon overlay

* Update units for floating button display

* Fix weapon skill overlay

* Add a specific variable for the save UI red

* Fix save button states

* Update raid combobox triggers

* Fix segmented controls

* Fix popover triggers

This is mostly a duplicate of SelectTrigger but CSS modules are deeply stupid, so we have to duplicate the code.

* Fix select classes

* Fix select item classes

* Fix context menus

* Remove console.log

* Update filter bar button

* Updated Select and SelectItem

Part of this was combining PictureSelectItem and SelectItem, so the former has been removed.

* Updated TableField and SelectTableField

* Updated toasts

* Updated AccountModal

* Added new themes and variables

* Fix hovercards

* Extracted header into HovercardHeader component

* Button improvements

* Allow for passing className to left and right accessory
* Rename contained to bound
* Rename buttonSize to size
* Add custom button styles

* Fix search filters

* Update styles for all search filters
* Make search filters function better on mobile
* Small refactor on individual filter bar files to extract individual search filter rendering into variables

* Update search modal styles

* Update input

Make a consistent height with select triggers and fix props

* Fix ExtraSummons and rename to ExtraSummonsGrid

* Fix search result item styles

* Update party footer

* Add segmented control to swap between remixes and description
* Fix styles

* Add local transition to overlay

* Pass down class name to Popover

* Other style changes for raid combobox
* Local keyframe animation

* Fix slider and switch components

* Update table field components

The structure of TableField's image props have changed

* Update PartyHeader and DropdownMenuItem

* Remove extraneous states and hooks from PartyHeader
* Only show PartyDropdown if we are looking at an existing party
* Add destructive prop for DropdownMenuItem
* Remove extraneous classes from PartyDropdown
* Localize dropdown contents

* Fix alert styles and overlays

* Update alert styles
* Fix Overlay component to take onClick event handler as a prop

* Add local animation to Tooltip

* Update GridRep

* Update job-related components

* Update select component

* Align the popover
* Pass down classes from props
* Adds local animation
* Remove modal style
* Add full width style

* Update RaidCombobox and RaidItem

Also removes RaidSelect, which has been removed

* Update object reps for mobile

* Update static pages

* Update extra weapons section

* ExtraContainer split into ExtraContainerItem
* Updated Guidebook result item, grid and unit
* Updated extra weapons grid and weapon grid

* Add missing animations to Toast

* Moved components to a new filters folder

* Fix Youtube and empty state in PartyFooter

* Fixed Youtube embed styles
* Added empty state for description tab

* Extracted filter bar user info into a new component

* Removed LabelledInput

* Added new Textarea component

This is a content editable div to prepare for when we add tagging and formatting

* Fix placeholders in SummonUnit

* Add extra colors to WeaponUnit

* Updated WeaponLabelIcon styles

* Update button prop labels

* Update auth components

Just moving import order and changing an unused class name

* Increase visibility of segmented control on static page

* Update FilterBar location and more

* Updates FilterBar import location
* Extracts user info into UserInfo component

* Update localizations

* Update button prop labels

* Update UncapIndicator display styles

* Update ExtraSummons to ExtraSummonsGrid

* Use small-tablet breakpoint for party reps

* Update Input and InputTableField

* Added error and label to input, in a fieldset
* Updated prop labels in InputTableField

* Center text on triggers on small screen sizes

* Update SelectGroup styles

* Update GridRep

* Remove link to user's profile—it was very distracting
* Increase mainhand max height so it doesn't appear too small when reps are larger

* Update SegmentedControl

* Forward refs to SegmentedControl
* Allow passing of className via props
* Specific styles for RaidCombobox and something else
* Use small-tablet breakpoint

* Update Segment styles

Notably, there's a nice transition now

* Remove unused style import

* Add custom Button styles

* Update proficiency typing

* Update PartyHeader and fix behavior

* Send true to editable prop is party is editable
* Fix turn count token display
* Fix party name style
* Add custom classes to various Buttons
* Only show PartyDropdown if a party is new
* Determine which buttons to show based on editable prop, not snapshot
* Remove unused code from Header
* Make new button route shallowly

* Add small-tablet breakpoint

* Update themes and variables

* Update globals.scss

* Don't show <img> when there is no icon

* Add prop for destructive dropdown menu items

* Update localizations

* Remove unused code

Dependencies and components that were no longer used

* Add lodash.isequal

We didn't end up using it but it might come in handy in the future

* Add custom styles for remixed pill

This pill displays when a party is a remix. We shrunk it so it wasn't quite the size of a normal small button, and then added disabled states for if the original party was deleted

* Use CSS modules with Command

We don't really use all of these exports, but we made it so that className gets passed properly to `styles` when we do

* Update DialogContent

* Shrunk max-height to 60vh, and remove it for search
* Added an explicit width, as using min/max-width interferes with the contentEditable div in EditPartyModal
* Added custom styles for EditPartyModal
* Removed unused styles

* Revert Command changes

This seems to rely on these specific styles and it works, so we'll leave it alone for now.

* Give visual focus state to close button

* Update DurationInput and remove old classes

* Update Input

* Add fieldsetClasses prop
* Fallback to an empty string if value is undefined
* Fix focus ring to be consistent with our other custom focus rings
* Fix placeholder color

* Hide text overflow in trigger

The Popover trigger (specifically for RaidCombobox) would stretch or break lines when given a long value. This makes it so that the text will always stay on one line and hide its overflow with an ellipsis if necessary

* Passes along the autoFocus prop to Select

This passes along the autoFocus prop to the root Select component, and exposes it in SelectTableField

* Fixes bug with SliderTablefield control

This fixes a bug where the SliderTableField's slider was not changing the input's value.

We essentially let the parent component control the value so the component is only ever reading from props, instead of using its stored state as a display.

* Fix placeholder text and formatting

This fixes Textarea's placeholder text to be consistent with Input, as well as allows us to use new lines in the placeholder

* Update ErrorSection styles

* Update FilterModal

* Fixes spacing of interactive elements in FilterBar so they don't stretch according to content anymore
* Adds new `persistFilters` prop that determines whether the FilterBar should persist any filters to the user's cookies
* Uses defaultFilterset prop to populate the default filter set instead of importing the actual "default filter set" and using it directly

* Update FilterModal

* Adds a notice alerting users that filters on profiles and the saved page do not persist
* Exposes `persistFilters` prop that will be passed to FilterBar and used to determine if the notice should be displayed
* Autofocuses the first select on the page

* Fix visual bugs in GridRep

* Fixes the mainhand height not always being full height when the container was being responsively resized
* Adjusts the color of empty grid rectangles for dark and light mode and when being hovered over

* Remove unused code

* Update EditPartyModal

* Directly adds shadow code from DialogHeader since this dialog behaves slightly differently. In the future, we'd like to reconcile this so that the code only appears once
* Changes rendering functions to be properties
* Add DialogHeader and DialogFooter
* Implement Textarea component instead of raw textarea
* Removed unused code

* Update Party component

* Moves tab state management to the parent to prevent flickering and re-rendering
* Fixes local ID saving so that unauth users can make parties again
* Fixes the saving and display of numeric values (button count, chain count, turn count)

* Add functionality to PartyFooter buttons

The "Edit info" and "Remix" buttons now have their proper functionality in PartyFooter, matching how they behave in PartyHeader

* Update PartyHeader

* Fixes the display of numeric properties (button count, turn count, chain count)
* Refactors remixed pill/button so that it displays a message if the original party was deleted

* Add missing localization

* Fix raid keyboard navigation

* We added a plain "raid" style that our keyboard navigation code can hook onto, so that you can navigate the RaidCombobox raid list with the up and down arrow keys
* Fixed the raid item background color when hovering or focused
* Removed unused code

* Add class to fieldset instead of input

* Don't show quick summon icon on subaura summons

* Update styles for extra weapon units

* Implement filter changes

User profiles and saved teams won't use a user's filter cookies or persist filters anymore

* Add missing localization for "Loading..."

* Add tab management to pages

Tab management was previously handled by `Party` but things are smoother and less flicker-y if we handle them on the pages themselves

* Update localizations

* Extract createLocalId into a util

We extracted createLocalId into a method outside of the new page. Now, it can be used as a fallback when fetching the local ID if that local ID doesn't exist yet

* Add permissive filter set

This is the default filter set on user profiles and the saved teams page

* Add a bunch of new colors and theme variables

* Notice variables for FilterModal
* Unit background variables for GridRep
* An array of accent yellow colors
* Modified disabled button values in dark theme
* Modified extra purple text color in dark theme

* Change NotFound to be a class instead of ID

* Move slideRight animation into Toast component

* Remove keyframes.scss

Unfortunately, CSS modules makes it unreasonably difficult to have a central repository of CSS animations and reuse them, so we have copied these into the stylesheets of components that use them.

* Remove keyframes.scss from globals

* Update styles for conflict modals

* The actual styles for these were in DialogContent and had been deleted, so we fetched them from a previous commit
* Conflict modals get added to the exception that gives them a taller max height
* We can probably combine the meat of these into a ConflictDiagram component

* Add keys to conflict buttons

* Fix conflict CSS

Was accidentally adding it to a declaration that was setting min-height instead of max-height

* Fix character conflict modal only appearing once

We weren't changing the modal open state to false

* Alert overlays should display over modals

We were using the same Overlay with no changes, so alerts would display over modals without an overlay behind them

* Add missing localization for earring errors

* Normalize over mastery object

The over mastery object was sometimes 0-index, sometimes 1-index. This normalizes it to be 1-indexed, even though that is a little silly. I think this is the lesser amount of work though, since normalizing against 0-index might require API changes

* Fix ExtendedMasterySelect styles

* Fix RingSelect styles and functionality

* Updates styles for CSS modules
* Updates for normalized 1-index object
* Properly falls back to 0 value if value is not set

* Normalize 1-index for over mastery

* Fix AwakeningSelectWithInput styles and functionality

* Adapts styles for CSS modules
* Properly sends validity
* Reordered errors

* Fix SelectWithInput styles and functionality

* Adapts styles for CSS modules
* Add name to errors
* Properly sends validity

* Add extra modifier styles to Input/Select

* Update CharacterModal

* Adapts styles for CSS modules
* Adds an alert if the user tries to close a dialog with changes without saving
* Uses constants instead of functions for rendering helpers
* Fixes validation

* Reset values when the dialog is closed

The way we handle state means that we will keep old, unsaved values around if we don't do this

* Move GridWeaponObject to types

* Add unsaved changes localizations

* Localize unsaved changes alert

* Increase spacing of range mod style

* Update ElementToggle to use CSS modules

* Refactor WeaponKeySelect

No longer makes an API call for each instantiation—instead we use the weapon keys downloaded on the server

* Update AxSelect for CSS modules

* Update weapon should happen in WeaponUnit

Previously, this happened in WeaponModal. It happens in CharacterUnit on that end, so this change brings us in line with how we're doing things elsewhere

* Update WeaponModal to incorporate latest changes

* Adds unsaved changes alert
* Updates to use refactored WeaponKeySelect
* Moves api code to parent via a updateWeapon prop
* Updates to use DialogHeader and DialogFooter
* Makes rendering functions into constants

* Set grid weapon element when downloaded

* Make things that should be bound, bound

* Update elemental colors

This makes elemental accent colors themed more consistently

* Add confirmation alert to Edit Party modal

* Fix how description is tested for changes

* Fix footer shadow in EditPartyModal

* Fix footer shadows for all other modals

Also removes default box-shadow and border-top

* Fix awakening modification check

Awakening wasn't being set when the modal loaded, so it was testing the gridWeapon value against undefined

* Use new element variables

* h5 in globals
* Buttons

* Don't show icon for balanced character awakening

Also, remove old CSS

* Small cleanup of parseInt

* Fix weapon element logic

We had broken null weapons changing sprites when the element was changed, and the change detection was also broken. Some more stringent logic checks fixed both.

* Fix more raid color stuff

This should be it for real this time

* Show AX section in WeaponHovercard

Was testing truthy/falsy which meant id 0 made it not display

* Fix padding so focus ring isn't cut off

* Refactor Header and add logout confirmation

* Fix page navigation when filtering collections

There was a bug that kept page navigation from working properly when filtering. Things would load multiple times, or load the wrong thing, or not navigate properly. That should all be fixed now.

* Fix styles for when a collection has no teams

* Fix Nextjs build errors
2023-07-04 00:43:49 -07:00
8cbdb1838d
Fix styles and re-add server availability message (#334)
* Revert server availability code

Not ready for primetime

* Fix some global styles

* Add extra padding

* Re-add server availability code

* Fix some global styles

* Add extra padding

* Re-add server availability code
2023-06-23 11:57:09 -07:00
9ecba12421
Revert server availability code (#333)
Not ready for primetime
2023-06-22 23:25:33 -07:00
9ed3a89a72 Revert "Remove server availability code"
This reverts commit e146453b31.
2023-06-22 23:23:28 -07:00
e146453b31 Remove server availability code
Not ready for prime time
2023-06-22 23:22:57 -07:00
2f572dc71c
Fix RaidCombobox placeholder and Select styles (#332)
* Fix server unavailable message

Booleans are hard

* Remove a log

* Add characters from 2023-06 Flash Gala

* Add "all battles" string

* Fix placeholder for RaidCombobox in FilterBar

* Fix select trigger styles
2023-06-22 02:29:22 -07:00
b94ff33d04
Add updates from 2023/06 Flash Gala (#331)
* Fix server unavailable message

Booleans are hard

* Remove a log

* Add characters from 2023-06 Flash Gala
2023-06-22 02:05:52 -07:00
686729ff9c
Add server unavailable message (#330)
* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Redesigned team navigation (#310)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Remove preview when on mobile sizes

* Implement raid combobox (#311)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Add raid placeholder string to locale

* Update .gitignore

* Update and reorganize localization files

* Update types

Added RaidGroup and updated Raid, then updated dependent types and objects

* Update dependencies

* Update react and react-dom to at least 18.0.0
* Install cmdk

* Rename Arrow.svg to Chevron.svg

Also added a new Arrow.svg with a stem

* Add api call for raidGroups and update pages

Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead

* Update SegmentedControl component

* Add className and blended properties
* Segment gets flex-grow

* Update Select component

* data-placeholder style should match only if true
* Adjust corner radius to match cards instead of inputs
* Fix classNames call in SelectItem

* Remove raid prop from Party

* Add Popover component

* Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox.
* Move styles that were in PopoverContent.scss to Popover.scss

* Add Command component

The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox.

* Add RaidCombobox and RaidItem components

* RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them.
* RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements

* Updates themes and variables

* Replace RaidDropdown with RaidCombobox

* Add small shadow to Tooltip

* Update side offset for Popover

* Update CharLimitedFieldset class name

* Add clear button to Combobox input

* It only shows up when there is text in the input
* Clicking it clears the text in the input
* It uses CharLimitedFieldset's classes

* ChatGPT helped me refactor RaidCombobox

* Further refactoring of RaidCombobox

* Deploy content update (#309)

* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Make combobox keyboard accessible

* Style updates

* Refactor accessibility code

* Add translation for "Selected" text

* Change selects to be poppers for consistency

We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior

* Set raid groups on teams page

* Implement in FilterBar

* Fix styles for combobox input

* Remove RaidDropdown component

* Update index.scss

* Remove preview when on mobile sizes

* Fix some mobile styles

* Add farming raid option

* Increase height slightly

* Small refactor

* Implement Edit team modal (#312)

* Small refactor to CharLimitedFieldset

Some methods were renamed for clarity. <input> props are actually put on the input properly.

* Add tabindex to Popover trigger

* Add tabindex to Switch and SwitchTableField

* Add tabindex to DurationInput

* Add new properties

* Added guidebooks to RaidGroup
* Added auto_summon to Party

* Conditionally render description in TableField

* Improve SwitchTableField

* Add support for passing in classes
* Add support for passing a disabled prop
* Pass description to TableField
* Right-align switch
* Add support for Extra color switch

* Align SliderTableField input to right

* Align SelectTableField input to right

* Update placeholder styles

* Fix empty state on DurationInput

* Remove tabindex from DurationInput

* Update InputTableField

Allow for passing down input properties and remove fixed width

* Fix dialog footer styles

* Update dialog and overlay z-index

* Add styles to TableField

Added styles for numeric inputs, disabled inputs, and generally cleaning things up

* Add guidebooks to RaidCombobox + styles

* Added guidebooks to the dummy raid group
* Fix background color
* Make less tall

* Implement EditPartyModal

EditPartyModal takes functionality that was in PartyHeader and puts it in a modal dialog. This lets us add fields and reduces the complexity of other components. Translations were also added.

* Remove edit functionality

* Add darker shadow to Select

* Properly send raid ID to server

* Show Extra grids based on selected raid

* Fix EX badge colors

* Use child as value in normal textarea

* Remove toggle ability from Extra grids

* Remove edit functionality from PartyDetails

* Fix type error

* Add quick summons (#313)

* Delete yarn.lock

* Add quick summon endpoint

* Add quick summon to GridSummon type

* Add icons

* Add quick summon to SummonUnit

* Quick summon icon is displayed on hover
* Updates the server when clicked

* Fix spacing on WeaponGrid

* Fixes for reactivity and performance (#314)

* Remove editable styles

* Use snapshot for segment reps

Using snapshots lets that data be reactive.

Also removed extra dependencies and fixed a bug in how SummonRep displayed sub-summons

* Don't display QuickSummon on friends, subaura

* Hotfix refreshing when switching tabs

* Another hotfix for tab switching

* Update awakening (#315)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add order to NO_AWAKENING

* Add ability to remove job skills (#317)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add max-height to Select

* Allow styling of Select modal with className prop

* Add Job class to Job select

* Add localizations for removing job skills

* Add endpoint for removing job skills

* Implement removing job skills

We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary

* Update README.md

Update with new static asset directories

* Quality pass (#326)

* Move min-width to RaidCombobox, not Popover

This fixes #318

* Use snapshots to make tokens reactive

This fixes #319

* Revert ChatGPT refactor of this method

Oops. This code was nice, but it didn't actually assign `false` to keys to be sent to the server. We will revisit this, but it needs to be fixed right now.

This fixes #325

* Ignore gacha directory

We will probably scrape these images soon.

* Add translation for Auto Summon token

* Add auto summon token to app state

* Set battle settings in state on update

Also renames PartyDetails to PartyFooter and makes description reactive

* Stop 1password icon from appearing in name field

* Use snapshot for reactive Edit party modal

* Fix Edit modal placeholder colors

* Fix bug with RaidCombobox and Farming

Selecting farming then opening the raid combobox *twice* consecutively would put you in no segment, so no raids appeared

Fixes #323

* Fix values staying in Edit team even if not saved

The values a user entered in the Edit team modal would persist even if the user hit cancel to close the modal. They wouldn't save to the server, but very confusing nonetheless. Now fixed.

* Fix unreadable colors in ElementToggle

* Fix button alignment in weapon modal

* Add text to filters button on small screens

The FilterBar showed a left aligned filter icon on mobile for months and it was driving me insane

* Remove extraneous code from Header

Including the party name, since it's at the top now

* Fix Alert at small sizes

* Make copy link toast work again

* Remove stylesheet links

* Fix remix toasts and alerts from both locations

The remix toast and alert was barely hooked up and not showing up when invoked from PartyHeader.

It now shows up whether you remix your own team (from PartyDropdown) or if you remix another person's team (from PartyHeader).

* Add a message if the server goes down

Right now the app fails silently if the server becomes unreachable. Now, we use the version check to determine if we have a connection to the server, and if not we display an error message.
2023-06-22 01:50:31 -07:00
c08204dd1b
Deploy quality fixes (#328)
* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Redesigned team navigation (#310)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Remove preview when on mobile sizes

* Implement raid combobox (#311)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Add raid placeholder string to locale

* Update .gitignore

* Update and reorganize localization files

* Update types

Added RaidGroup and updated Raid, then updated dependent types and objects

* Update dependencies

* Update react and react-dom to at least 18.0.0
* Install cmdk

* Rename Arrow.svg to Chevron.svg

Also added a new Arrow.svg with a stem

* Add api call for raidGroups and update pages

Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead

* Update SegmentedControl component

* Add className and blended properties
* Segment gets flex-grow

* Update Select component

* data-placeholder style should match only if true
* Adjust corner radius to match cards instead of inputs
* Fix classNames call in SelectItem

* Remove raid prop from Party

* Add Popover component

* Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox.
* Move styles that were in PopoverContent.scss to Popover.scss

* Add Command component

The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox.

* Add RaidCombobox and RaidItem components

* RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them.
* RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements

* Updates themes and variables

* Replace RaidDropdown with RaidCombobox

* Add small shadow to Tooltip

* Update side offset for Popover

* Update CharLimitedFieldset class name

* Add clear button to Combobox input

* It only shows up when there is text in the input
* Clicking it clears the text in the input
* It uses CharLimitedFieldset's classes

* ChatGPT helped me refactor RaidCombobox

* Further refactoring of RaidCombobox

* Deploy content update (#309)

* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Make combobox keyboard accessible

* Style updates

* Refactor accessibility code

* Add translation for "Selected" text

* Change selects to be poppers for consistency

We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior

* Set raid groups on teams page

* Implement in FilterBar

* Fix styles for combobox input

* Remove RaidDropdown component

* Update index.scss

* Remove preview when on mobile sizes

* Fix some mobile styles

* Add farming raid option

* Increase height slightly

* Small refactor

* Implement Edit team modal (#312)

* Small refactor to CharLimitedFieldset

Some methods were renamed for clarity. <input> props are actually put on the input properly.

* Add tabindex to Popover trigger

* Add tabindex to Switch and SwitchTableField

* Add tabindex to DurationInput

* Add new properties

* Added guidebooks to RaidGroup
* Added auto_summon to Party

* Conditionally render description in TableField

* Improve SwitchTableField

* Add support for passing in classes
* Add support for passing a disabled prop
* Pass description to TableField
* Right-align switch
* Add support for Extra color switch

* Align SliderTableField input to right

* Align SelectTableField input to right

* Update placeholder styles

* Fix empty state on DurationInput

* Remove tabindex from DurationInput

* Update InputTableField

Allow for passing down input properties and remove fixed width

* Fix dialog footer styles

* Update dialog and overlay z-index

* Add styles to TableField

Added styles for numeric inputs, disabled inputs, and generally cleaning things up

* Add guidebooks to RaidCombobox + styles

* Added guidebooks to the dummy raid group
* Fix background color
* Make less tall

* Implement EditPartyModal

EditPartyModal takes functionality that was in PartyHeader and puts it in a modal dialog. This lets us add fields and reduces the complexity of other components. Translations were also added.

* Remove edit functionality

* Add darker shadow to Select

* Properly send raid ID to server

* Show Extra grids based on selected raid

* Fix EX badge colors

* Use child as value in normal textarea

* Remove toggle ability from Extra grids

* Remove edit functionality from PartyDetails

* Fix type error

* Add quick summons (#313)

* Delete yarn.lock

* Add quick summon endpoint

* Add quick summon to GridSummon type

* Add icons

* Add quick summon to SummonUnit

* Quick summon icon is displayed on hover
* Updates the server when clicked

* Fix spacing on WeaponGrid

* Fixes for reactivity and performance (#314)

* Remove editable styles

* Use snapshot for segment reps

Using snapshots lets that data be reactive.

Also removed extra dependencies and fixed a bug in how SummonRep displayed sub-summons

* Don't display QuickSummon on friends, subaura

* Hotfix refreshing when switching tabs

* Another hotfix for tab switching

* Update awakening (#315)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add order to NO_AWAKENING

* Add ability to remove job skills (#317)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add max-height to Select

* Allow styling of Select modal with className prop

* Add Job class to Job select

* Add localizations for removing job skills

* Add endpoint for removing job skills

* Implement removing job skills

We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary

* Update README.md

Update with new static asset directories

* Quality pass (#326)

* Move min-width to RaidCombobox, not Popover

This fixes #318

* Use snapshots to make tokens reactive

This fixes #319

* Revert ChatGPT refactor of this method

Oops. This code was nice, but it didn't actually assign `false` to keys to be sent to the server. We will revisit this, but it needs to be fixed right now.

This fixes #325

* Ignore gacha directory

We will probably scrape these images soon.

* Add translation for Auto Summon token

* Add auto summon token to app state

* Set battle settings in state on update

Also renames PartyDetails to PartyFooter and makes description reactive

* Stop 1password icon from appearing in name field

* Use snapshot for reactive Edit party modal

* Fix Edit modal placeholder colors

* Fix bug with RaidCombobox and Farming

Selecting farming then opening the raid combobox *twice* consecutively would put you in no segment, so no raids appeared

Fixes #323

* Fix values staying in Edit team even if not saved

The values a user entered in the Edit team modal would persist even if the user hit cancel to close the modal. They wouldn't save to the server, but very confusing nonetheless. Now fixed.

* Fix unreadable colors in ElementToggle

* Fix button alignment in weapon modal

* Add text to filters button on small screens

The FilterBar showed a left aligned filter icon on mobile for months and it was driving me insane

* Remove extraneous code from Header

Including the party name, since it's at the top now

* Fix Alert at small sizes

* Make copy link toast work again

* Remove stylesheet links

* Fix remix toasts and alerts from both locations

The remix toast and alert was barely hooked up and not showing up when invoked from PartyHeader.

It now shows up whether you remix your own team (from PartyDropdown) or if you remix another person's team (from PartyHeader).
2023-06-21 03:44:43 -07:00
b8ae43ddaf
June 2023 Update (#316)
* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Redesigned team navigation (#310)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Remove preview when on mobile sizes

* Implement raid combobox (#311)

* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Add raid placeholder string to locale

* Update .gitignore

* Update and reorganize localization files

* Update types

Added RaidGroup and updated Raid, then updated dependent types and objects

* Update dependencies

* Update react and react-dom to at least 18.0.0
* Install cmdk

* Rename Arrow.svg to Chevron.svg

Also added a new Arrow.svg with a stem

* Add api call for raidGroups and update pages

Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead

* Update SegmentedControl component

* Add className and blended properties
* Segment gets flex-grow

* Update Select component

* data-placeholder style should match only if true
* Adjust corner radius to match cards instead of inputs
* Fix classNames call in SelectItem

* Remove raid prop from Party

* Add Popover component

* Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox.
* Move styles that were in PopoverContent.scss to Popover.scss

* Add Command component

The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox.

* Add RaidCombobox and RaidItem components

* RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them.
* RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements

* Updates themes and variables

* Replace RaidDropdown with RaidCombobox

* Add small shadow to Tooltip

* Update side offset for Popover

* Update CharLimitedFieldset class name

* Add clear button to Combobox input

* It only shows up when there is text in the input
* Clicking it clears the text in the input
* It uses CharLimitedFieldset's classes

* ChatGPT helped me refactor RaidCombobox

* Further refactoring of RaidCombobox

* Deploy content update (#309)

* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters

* Make combobox keyboard accessible

* Style updates

* Refactor accessibility code

* Add translation for "Selected" text

* Change selects to be poppers for consistency

We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior

* Set raid groups on teams page

* Implement in FilterBar

* Fix styles for combobox input

* Remove RaidDropdown component

* Update index.scss

* Remove preview when on mobile sizes

* Fix some mobile styles

* Add farming raid option

* Increase height slightly

* Small refactor

* Implement Edit team modal (#312)

* Small refactor to CharLimitedFieldset

Some methods were renamed for clarity. <input> props are actually put on the input properly.

* Add tabindex to Popover trigger

* Add tabindex to Switch and SwitchTableField

* Add tabindex to DurationInput

* Add new properties

* Added guidebooks to RaidGroup
* Added auto_summon to Party

* Conditionally render description in TableField

* Improve SwitchTableField

* Add support for passing in classes
* Add support for passing a disabled prop
* Pass description to TableField
* Right-align switch
* Add support for Extra color switch

* Align SliderTableField input to right

* Align SelectTableField input to right

* Update placeholder styles

* Fix empty state on DurationInput

* Remove tabindex from DurationInput

* Update InputTableField

Allow for passing down input properties and remove fixed width

* Fix dialog footer styles

* Update dialog and overlay z-index

* Add styles to TableField

Added styles for numeric inputs, disabled inputs, and generally cleaning things up

* Add guidebooks to RaidCombobox + styles

* Added guidebooks to the dummy raid group
* Fix background color
* Make less tall

* Implement EditPartyModal

EditPartyModal takes functionality that was in PartyHeader and puts it in a modal dialog. This lets us add fields and reduces the complexity of other components. Translations were also added.

* Remove edit functionality

* Add darker shadow to Select

* Properly send raid ID to server

* Show Extra grids based on selected raid

* Fix EX badge colors

* Use child as value in normal textarea

* Remove toggle ability from Extra grids

* Remove edit functionality from PartyDetails

* Fix type error

* Add quick summons (#313)

* Delete yarn.lock

* Add quick summon endpoint

* Add quick summon to GridSummon type

* Add icons

* Add quick summon to SummonUnit

* Quick summon icon is displayed on hover
* Updates the server when clicked

* Fix spacing on WeaponGrid

* Fixes for reactivity and performance (#314)

* Remove editable styles

* Use snapshot for segment reps

Using snapshots lets that data be reactive.

Also removed extra dependencies and fixed a bug in how SummonRep displayed sub-summons

* Don't display QuickSummon on friends, subaura

* Hotfix refreshing when switching tabs

* Another hotfix for tab switching

* Update awakening (#315)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add order to NO_AWAKENING

* Add ability to remove job skills (#317)

* Add Awakening type and remove old defs

We remove the flat list of awakening data, as we will be pulling data from the database

* Update types to use new Awakening type

* Update WeaponUnit for Grand weapon awakenings

* Update object modals

We needed to update CharacterModal and WeaponModal to display awakenings from the new data format. However, the component used (`SelectWithInput`) was tied to AX Skills in a way that would take exponentially more time to resolve.

Instead, we forked `SelectWithInput` into `AwakeningSelectWithInput` and did our work there.

`AwakeningSelect` was found to be redundant, so it was removed.

* Update hovercards

* Add max-height to Select

* Allow styling of Select modal with className prop

* Add Job class to Job select

* Add localizations for removing job skills

* Add endpoint for removing job skills

* Implement removing job skills

We added a (...) button next to each editable job skill that opens a context menu that will allow the user to remove the job skill. An alert is presented to make sure the user is sure before proceeding.

As part of this change, some minor restyling of JobSkillItem was necessary
2023-06-19 03:54:03 -07:00
363148599a
Deploy content update (#309)
* Update the updates page with new items (#306)

* Add Nier and Estarriola uncaps (#308)

* Update the updates page with new items (#306) (#307)

* Update .gitignore

* Add Nier and Estarriola uncaps

* Fix uncaps treated as new characters
2023-06-08 12:21:00 -07:00
d2cb881640
Update the updates page with new items (#306) (#307) 2023-05-31 03:26:11 -07:00
1c2a1b6bb4
Deploy organization and bug fixes (#299)
* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added World Series to weapon series empty state (#293)

* Push 2023/03 updates to main (#292)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Enables advanced filters in collections (#289)

* Add skeleton of FilterModal

* Install react-slider from Radix

* Move AccountModal styles to more generic place

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Change enabled switch color

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Add fast-deep-equal package

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* Implement advanced filters on Teams page

* Add skeleton of FilterModal

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* GridRep adjustments

* Properly unset mainhand when cells get reused and the new team doesnt have one
* Slightly better styling to make the grid more correct

* Fix bad merge

* Add advanced filter support to saved and profile pages

* Fix auto guard text

* Ensure fetchTeams callback is updated with filters

* Add auto guard icon to GridRep

* Disable max buttons and turns

* Fix build errors

* Organize components (#298)

* Deploy advanced filters (#297)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added World Series to weapon series empty state (#293)

* Push 2023/03 updates to main (#292)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Enables advanced filters in collections (#289)

* Add skeleton of FilterModal

* Install react-slider from Radix

* Move AccountModal styles to more generic place

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Change enabled switch color

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Add fast-deep-equal package

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* Implement advanced filters on Teams page

* Add skeleton of FilterModal

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* GridRep adjustments

* Properly unset mainhand when cells get reused and the new team doesnt have one
* Slightly better styling to make the grid more correct

* Fix bad merge

* Add advanced filter support to saved and profile pages

* Fix auto guard text

* Ensure fetchTeams callback is updated with filters

* Add auto guard icon to GridRep

* Disable max buttons and turns

* Fix build errors

* Organize components into folders

* Fix transcendence popover levels

* Remove extra styles

* Add Storybook

* Delete Home.module.css

* Update paths

* Fix popover arrow

* Fix import paths

* Fix scrollbars in search

* Add type to further fix TranscendencePopover

* Add background-size to 1x in hidpiImage mixin

* Fix hovercard scrollbar

* Move components

* Fix ElementToggle on smaller devices

* Change default filterset

Min characters should be 2, not 3
2023-04-12 06:37:41 -07:00
968ae5c41e
Deploy advanced filters (#297)
* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added World Series to weapon series empty state (#293)

* Push 2023/03 updates to main (#292)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Enables advanced filters in collections (#289)

* Add skeleton of FilterModal

* Install react-slider from Radix

* Move AccountModal styles to more generic place

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Change enabled switch color

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Add fast-deep-equal package

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* Implement advanced filters on Teams page

* Add skeleton of FilterModal

* Make generic TableField and move styles

This is so we have a base for other table rows that use different interactive elements

* Implement custom Slider component

This inherits from Radix's Slider

* Implement SliderTableField

* Implemented SwitchTableField

* Implement InputTableField

* Update modal skeleton

* Added localizations for Advanced filters

* Update styles for various components

Added some new colors and fixed spacing

* Added value reporting and fixed a cycle error

* Added default values, clearing filters, etc

* Default values
* Ability to clear filters
* Receiving values from components

* Fix maximum cycle depth exceeded error

* Update TableFields to not error

Also optional value is required

* Create FilterSet.d.ts

* Send filtersets to FilterModal

This sends the default filterset and the user's filterset to the filter modal.

The default filterset is used when resetting all filters. The users filterset is used so that it is populated with the user's values when they open the modal

* Add new localizations

* Change types and add default filterset object

* Change value in table fields

* Input table fields need to be able to be empty
* Slider table fields should default to 0 if value isn't provided

* Set width of Select in table field in Filter dialog

* Add style for filter button with filters active

* Swap to using selects for some boolean fields

Charge Attack, Full Auto, and Auto Guard are not boolean values since the user can select (and the default should be) to show both on and off values. We swap to using a SelectTableField here to represent this difference.

We also added logic for Full Auto and Auto Guard fields since they are tied together in some cases (you can't show Auto Guard teams that have Full Auto disabled)

* Populate values from defaultFilterSet

* Update how we save and propagate filters

We save filterset in a local state, because the FilterBar will send it down to us from cookies.

We then set each individual property from that filter set.

We set inputs to have a placeholder, as max buttons and max turns could not be set (null). Then, we only send those fields when they have a value provided by the user.

* Remove default filterset

This was moved to a utils/ file

* Propagate filters from modal

This updates how we handle filter propagation to accommodate the advanced ones. The icon lights up when filters are active.

* GridRep adjustments

* Properly unset mainhand when cells get reused and the new team doesnt have one
* Slightly better styling to make the grid more correct

* Fix bad merge

* Add advanced filter support to saved and profile pages

* Fix auto guard text

* Ensure fetchTeams callback is updated with filters

* Add auto guard icon to GridRep

* Disable max buttons and turns

* Fix build errors
2023-04-09 19:40:15 -07:00
7b54791bb3
Deploy 13th weapon slot (#296)
* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added World Series to weapon series empty state (#293)

* Push 2023/03 updates to main (#292)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Enable 13th slot

* Enable 13th weapon slot (#295)
2023-04-02 01:30:49 -07:00
4783b7eaae
Deploy hotfix for World Series bug (#294)
* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added World Series to weapon series empty state (#293)

* Push 2023/03 updates to main (#292)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

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

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

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

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state
2023-04-01 12:22:31 -07:00
595 changed files with 50590 additions and 20857 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

View file

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

11
.gitignore vendored
View file

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

2
.mise.toml Normal file
View file

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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

43
.storybook/main.ts Normal file
View file

@ -0,0 +1,43 @@
import type { StorybookConfig } from '@storybook/nextjs'
const path = require('path')
const config: StorybookConfig = {
stories: [
'../components/**/*.mdx',
'../components/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
{
name: '@storybook/addon-styling',
options: {
sass: {
// Require your Sass preprocessor here
implementation: require('sass'),
additionalData: `
@import "./styles/variables.scss";
`,
},
},
},
],
staticDirs: ['../public'],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
webpackFinal: async (config: any, { configType }) => {
config.resolve.roots = [
path.resolve(__dirname, '../public'),
'node_modules',
]
config.resolve.fallback.fs = false
return config
},
}
export default config

17
.storybook/preview.ts Normal file
View file

@ -0,0 +1,17 @@
import type { Preview } from '@storybook/react'
import '../styles/globals.scss'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
}
export default preview

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

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

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

@ -1,175 +0,0 @@
import React from 'react'
import Link from 'next/link'
import { Trans, useTranslation } from 'next-i18next'
import ShareIcon from '~public/icons/Share.svg'
import DiscordIcon from '~public/icons/discord.svg'
import GithubIcon from '~public/icons/github.svg'
import './index.scss'
interface Props {}
const AboutPage: React.FC<Props> = (props: Props) => {
const { t: common } = useTranslation('common')
const { t: about } = useTranslation('about')
return (
<div className="About PageContent">
<h1>{common('about.segmented_control.about')}</h1>
<section>
<h2>
<Trans i18nKey="about:about.subtitle">
Granblue.team is a tool to save and share team compositions for{' '}
<a
href="https://game.granbluefantasy.jp"
target="_blank"
rel="noreferrer"
>
Granblue Fantasy
</a>
, a social RPG from Cygames.
</Trans>
</h2>
<p>{about('about.explanation.0')}</p>
<p>{about('about.explanation.1')}</p>
<div className="Hero" />
</section>
<section>
<h2>{about('about.feedback.title')}</h2>
<p>{about('about.feedback.explanation')}</p>
<p>{about('about.feedback.solicit')}</p>
<div className="Discord LinkItem">
<Link href="https://discord.gg/qyZ5hGdPC8">
<a
href="https://discord.gg/qyZ5hGdPC8"
target="_blank"
rel="noreferrer"
>
<div className="Left">
<DiscordIcon />
<h3>granblue-tools</h3>
</div>
<ShareIcon className="ShareIcon" />
</a>
</Link>
</div>
</section>
<section>
<h2>{about('about.credits.title')}</h2>
<p>
<Trans i18nKey="about:about.credits.maintainer">
Granblue.team was built and is maintained by{' '}
<a
href="https://twitter.com/jedmund"
target="_blank"
rel="noreferrer"
>
@jedmund
</a>
.
</Trans>
</p>
<p>
<Trans i18nKey="about:about.credits.assistance">
Many thanks to{' '}
<a
href="https://twitter.com/lalalalinna"
target="_blank"
rel="noreferrer"
>
@lalalalinna
</a>{' '}
and{' '}
<a
href="https://twitter.com/tarngerine"
target="_blank"
rel="noreferrer"
>
@tarngerine
</a>
, who both provided a lot of help and advice as I was ramping up.
</Trans>
</p>
<p>
<Trans i18nKey="about:about.credits.support">
Many thanks also go to everyone in{' '}
<a
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
target="_blank"
rel="noreferrer"
>
Fireplace
</a>{' '}
and the granblue-tools Discord for all of their help with with bug
testing, feature requests, and moral support. (P.S. We&apos;re
recruiting!)
</Trans>
</p>
</section>
<section>
<h2>{about('about.contributing.title')}</h2>
<p>{about('about.contributing.explanation')}</p>
<ul className="Links">
<li className="Github LinkItem">
<Link href="https://github.com/jedmund/hensei-api">
<a
href="https://github.com/jedmund/hensei-api"
target="_blank"
rel="noreferrer"
>
<div className="Left">
<GithubIcon />
<h3>jedmund/hensei-api</h3>
</div>
<ShareIcon className="ShareIcon" />
</a>
</Link>
</li>
<li className="Github LinkItem">
<Link href="https://github.com/jedmund/hensei-web">
<a
href="https://github.com/jedmund/hensei-web"
target="_blank"
rel="noreferrer"
>
<div className="Left">
<GithubIcon />
<h3>jedmund/hensei-web</h3>
</div>
<ShareIcon className="ShareIcon" />
</a>
</Link>
</li>
</ul>
</section>
<section>
<h2>{about('about.license.title')}</h2>
<p>
<Trans i18nKey="about:about.license.license">
This app is licensed under{' '}
<a
href="https://choosealicense.com/licenses/agpl-3.0/"
target="_blank"
rel="noreferrer"
>
GNU AGPLv3
</a>
.
</Trans>
</p>
<p>{about('about.license.explanation')}</p>
</section>
<section>
<h2>{about('about.copyright.title')}</h2>
<p>{about('about.copyright.explanation')}</p>
</section>
</div>
)
}
export default AboutPage

View file

@ -1,28 +0,0 @@
.Account.DialogContent {
display: flex;
flex-direction: column;
gap: $unit-2x;
width: $unit * 64;
overflow-y: hidden;
.Fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit-4x;
@include breakpoint(phone) {
gap: $unit-4x;
}
}
.DialogDescription {
font-size: $font-regular;
line-height: 1.24;
margin-bottom: $unit;
&:last-of-type {
margin-bottom: 0;
}
}
}

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,37 +0,0 @@
.AwakeningSelect .AwakeningSet {
.errors {
color: $error;
display: none;
padding: $unit 0;
&.visible {
display: block;
}
}
.fields {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger {
flex-grow: 1;
}
.Label {
display: none;
flex-grow: 0;
&.Visible {
display: block;
width: auto;
}
.Input {
min-width: $unit * 12;
width: inherit;
}
}
}
}

View file

@ -1,97 +0,0 @@
import React, { useEffect, useState } from 'react'
import cloneDeep from 'lodash.clonedeep'
import SelectWithInput from '~components/SelectWithInput'
import { weaponAwakening, characterAwakening } from '~data/awakening'
import './index.scss'
interface Props {
object: 'character' | 'weapon'
type?: number
level?: number
onOpenChange?: (open: boolean) => void
sendValidity: (isValid: boolean) => void
sendValues: (type: number, level: number) => void
}
const AwakeningSelect = (props: Props) => {
// Data states
const [awakeningType, setAwakeningType] = useState(
props.object === 'weapon' ? 0 : 1
)
const [awakeningLevel, setAwakeningLevel] = useState(1)
// Data
const chooseDataset = () => {
let list: ItemSkill[] = []
switch (props.object) {
case 'character':
list = characterAwakening
break
case 'weapon':
// WARNING: Clonedeep is masking a deeper error
// which is running this method every time this component is rerendered
// causing multiple "No awakening" items to be added
const awakening = cloneDeep(weaponAwakening)
awakening.unshift({
id: 0,
name: {
en: 'No awakening',
ja: '覚醒なし',
},
granblue_id: '',
slug: 'no-awakening',
minValue: 0,
maxValue: 0,
fractional: false,
})
list = awakening
break
}
return list
}
// Set default awakening and level based on object type
useEffect(() => {
const defaultAwakening = props.object === 'weapon' ? 0 : 1
const type = props.type != undefined ? props.type : defaultAwakening
setAwakeningType(type)
setAwakeningLevel(props.level ? props.level : 1)
}, [props.object, props.type, props.level])
// Send validity of form when awakening level changes
useEffect(() => {
props.sendValidity(awakeningLevel > 0)
}, [props.sendValidity, awakeningLevel])
// Classes
function changeOpen(open: boolean) {
if (props.onOpenChange) props.onOpenChange(open)
}
function handleValueChange(type: number, level: number) {
setAwakeningType(type)
setAwakeningLevel(level)
props.sendValues(type, level)
}
return (
<div className="Awakening">
<SelectWithInput
object={`${props.object}_awakening`}
dataSet={chooseDataset()}
selectValue={awakeningType}
inputValue={awakeningLevel}
onOpenChange={changeOpen}
sendValidity={props.sendValidity}
sendValues={handleValueChange}
/>
</div>
)
}
export default AwakeningSelect

View file

@ -1,47 +0,0 @@
.AXSelect {
display: flex;
flex-direction: column;
gap: $unit;
.AXSet {
&.hidden {
display: none;
}
.errors {
color: $error;
display: none;
padding: $unit 0;
&.visible {
display: block;
}
}
.fields {
display: flex;
flex-direction: row;
gap: $unit;
.SelectTrigger {
flex-grow: 1;
margin: 0;
}
input {
-webkit-font-smoothing: antialiased;
border: none;
border-radius: $input-corner;
box-sizing: border-box;
display: none;
text-align: right;
min-width: $unit-14x;
width: 100px;
&.Visible {
display: block;
}
}
}
}
}

View file

@ -1,318 +0,0 @@
.Button {
align-items: center;
background: var(--button-bg);
border: none;
border-radius: $input-corner;
color: var(--button-text);
display: inline-flex;
font-size: $font-button;
font-weight: $normal;
gap: 6px;
transition: 0.18s opacity ease-in-out;
user-select: none;
&:hover,
&.Blended:hover,
&.Blended.Active {
background: var(--button-bg-hover);
cursor: pointer;
color: var(--button-text-hover);
.Accessory svg {
fill: var(--button-text-hover);
}
.Accessory svg.stroke {
fill: none;
stroke: var(--button-text-hover);
}
}
&.Blended {
background: transparent;
}
&.IconButton.medium {
height: inherit;
padding: $unit-half;
&:hover {
background: none;
}
.Text {
font-size: $font-small;
font-weight: $bold;
@include breakpoint(phone) {
display: none;
}
}
}
&.Contained {
background: var(--button-contained-bg);
&:hover {
background: var(--button-contained-bg-hover);
}
&.Save:hover .Accessory svg {
fill: #ff4d4d;
stroke: #ff4d4d;
}
&.Save {
color: #ff4d4d;
&.Active .Accessory svg {
fill: #ff4d4d;
stroke: #ff4d4d;
}
&:hover {
color: darken(#ff4d4d, 30);
.Accessory svg {
fill: darken(#ff4d4d, 30);
stroke: darken(#ff4d4d, 30);
}
}
}
}
&.Options {
box-shadow: 0px 1px 3px rgb(0 0 0 / 14%);
position: absolute;
left: 8px;
top: 8px;
z-index: 3;
}
&:disabled {
background-color: var(--button-bg-disabled);
color: var(--button-text-disabled);
&:hover {
background-color: var(--button-bg-disabled);
color: var(--button-text-disabled);
cursor: default;
}
}
&.medium {
height: $unit * 5.5;
padding: ($unit * 1.5) $unit-2x;
}
&.small {
padding: $unit * 1.5;
}
@include breakpoint(phone) {
&.destructive {
background: $error;
color: $grey-100;
.Accessory svg {
fill: $grey-100;
}
}
}
&.destructive:hover {
background: $error;
color: $grey-100;
.Accessory svg {
fill: $grey-100;
}
}
&.Save {
.Accessory svg {
fill: none;
stroke: var(--button-text);
}
&.Saved {
color: #ff4d4d;
.Accessory svg {
fill: #ff4d4d;
stroke: none;
}
}
&:hover {
color: #ff4d4d;
.Accessory svg {
fill: none;
stroke: #ff4d4d;
}
}
}
&.modal:hover {
background: $grey-90;
}
&.modal.destructive {
color: $error;
&:hover {
color: darken($error, 10);
}
}
.Accessory {
$dimension: $unit-2x;
display: flex;
&.Arrow {
margin-top: $unit-half;
}
svg {
fill: var(--button-text);
height: $dimension;
width: $dimension;
&.stroke {
fill: none;
stroke: var(--button-text);
}
&.Add {
height: 18px;
width: 18px;
}
&.Check {
height: 22px;
width: 22px;
}
}
&.check svg {
margin-top: 1px;
height: 14px;
width: auto;
}
svg &.settings svg {
height: 13px;
width: 13px;
}
}
&.btn-blue {
background: $blue;
color: #8b8b8b;
&:hover {
background: #4b9be5;
color: #233e56;
}
}
&.btn-red {
background: #fa4242;
color: #860f0f;
&:hover {
background: #e91a1a;
color: #4e1717;
.icon {
color: #4e1717;
}
}
.icon {
color: #860f0f;
}
}
&.btn-disabled {
background: #e0e0e0;
color: #bababa;
&:hover {
background: #e0e0e0;
color: #bababa;
}
}
&.null {
background: $grey-90;
color: $grey-55;
&:hover {
background: $grey-70;
color: $grey-15;
}
}
&.wind {
background: $wind-bg-20;
color: $wind-text-10;
&:hover {
background: darken($wind-bg-20, 10);
}
}
&.fire {
background: $fire-bg-20;
color: $fire-text-10;
&:hover {
background: darken($fire-bg-20, 10);
}
}
&.water {
background: $water-bg-20;
color: $water-text-10;
&:hover {
background: darken($water-bg-20, 10);
}
}
&.earth {
background: $earth-bg-20;
color: $earth-text-10;
&:hover {
background: darken($earth-bg-20, 10);
}
}
&.dark {
background: $dark-bg-10;
color: $dark-text-10;
&:hover {
background: darken($dark-bg-10, 10);
}
}
&.light {
background: $light-bg-20;
color: $light-text-10;
&:hover {
background: darken($light-bg-20, 10);
}
}
.Text {
color: inherit;
display: block;
width: 100%;
}
}

View file

@ -1,94 +0,0 @@
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import api from '~utils/api'
import './index.scss'
interface Props {
id: string
type: 'character' | 'summon' | 'weapon'
image?: '01' | '02' | '03' | '04'
}
const defaultProps = {
active: false,
blended: false,
contained: false,
buttonSize: 'medium' as const,
image: '01',
}
const ChangelogUnit = ({ id, type, image }: Props) => {
// Router
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// State
const [item, setItem] = useState<Character | Weapon | Summon>()
// Hooks
useEffect(() => {
fetch()
}, [])
async function fetch() {
switch (type) {
case 'character':
const character = await fetchCharacter()
setItem(character.data)
break
case 'weapon':
const weapon = await fetchWeapon()
setItem(weapon.data)
break
case 'summon':
const summon = await fetchSummon()
setItem(summon.data)
break
}
}
async function fetchCharacter() {
return api.endpoints.characters.getOne({ id: id })
}
async function fetchWeapon() {
return api.endpoints.weapons.getOne({ id: id })
}
async function fetchSummon() {
return api.endpoints.summons.getOne({ id: id })
}
const imageUrl = () => {
let src = ''
switch (type) {
case 'character':
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${id}_${image}.jpg`
break
case 'weapon':
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
break
case 'summon':
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
break
}
return src
}
return (
<div className="ChangelogUnit" key={id}>
<img alt={item ? item.name[locale] : ''} src={imageUrl()} />
<h4>{item ? item.name[locale] : ''}</h4>
</div>
)
}
ChangelogUnit.defaultProps = defaultProps
export default ChangelogUnit

View file

@ -1,38 +0,0 @@
.Limited {
$offset: 2px;
align-items: center;
background: var(--input-bg);
border-radius: $input-corner;
border: $offset solid transparent;
box-sizing: border-box;
display: flex;
gap: $unit;
padding-top: 2px;
padding-bottom: 2px;
padding-right: calc($unit-2x - $offset);
&:focus-within {
border: $offset solid $blue;
// box-shadow: 0 2px rgba(255, 255, 255, 1);
}
.Counter {
color: $grey-55;
font-weight: $bold;
line-height: 42px;
}
.Input {
background: transparent;
border: none;
border-radius: 0;
padding: $unit * 1.5 $unit-2x;
padding-left: calc($unit-2x - $offset);
&:focus {
border: none;
outline: none;
}
}
}

View file

@ -1,57 +0,0 @@
import React, { useEffect, useState } from 'react'
import './index.scss'
interface Props {
fieldName: string
placeholder: string
value?: string
limit: number
error: string
onBlur?: (event: React.ChangeEvent<HTMLInputElement>) => void
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
}
const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
function useFieldSet(props, ref) {
const fieldType = ['password', 'confirm_password'].includes(props.fieldName)
? 'password'
: 'text'
const [currentCount, setCurrentCount] = useState(0)
useEffect(() => {
setCurrentCount(
props.value ? props.limit - props.value.length : props.limit
)
}, [props.limit, props.value])
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
setCurrentCount(props.limit - event.currentTarget.value.length)
if (props.onChange) props.onChange(event)
}
return (
<fieldset className="Fieldset">
<div className="Limited">
<input
autoComplete="off"
className="Input"
type={fieldType}
name={props.fieldName}
placeholder={props.placeholder}
defaultValue={props.value || ''}
onBlur={props.onBlur}
onChange={onChange}
maxLength={props.limit}
ref={ref}
formNoValidate
/>
<span className="Counter">{currentCount}</span>
</div>
{props.error.length > 0 && <p className="InputError">{props.error}</p>}
</fieldset>
)
}
)
export default CharLimitedFieldset

View file

@ -1,39 +0,0 @@
#CharacterGrid {
display: flex;
flex-direction: column;
justify-content: center;
margin: auto;
max-width: $grid-width;
@include breakpoint(tablet) {
align-items: center;
}
}
#Characters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: $unit-3x;
margin: 0;
padding: 0;
max-width: $grid-width;
isolation: isolate;
@include breakpoint(tablet) {
gap: $unit-2x;
justify-content: space-between;
width: 100%;
}
// prettier-ignore
@media only screen
and (max-width: 500px)
and (max-height: 920px)
and (-webkit-min-device-pixel-ratio: 2) {
gap: $unit;
}
& > li:last-child {
margin: 0;
}
}

View file

@ -1,78 +0,0 @@
.Character.DialogContent {
gap: $unit;
min-width: 480px;
@include breakpoint(phone) {
min-width: inherit;
}
.DialogHeader {
transition: 0.18s padding-top ease-in-out;
position: sticky;
top: 0;
&.Scrolled {
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
padding-top: $unit-2x;
}
img {
transition: 0.2s width ease-in-out;
width: $unit-6x !important;
}
.DialogTitle {
font-size: $font-large;
}
.SubTitle {
display: none;
}
}
.mods {
display: flex;
flex-direction: column;
gap: $unit-4x;
padding: 0 $unit-4x $unit-2x;
section {
display: flex;
flex-direction: column;
gap: $unit-half;
&.inline {
align-items: center;
flex-direction: row;
justify-content: space-between;
h3 {
margin: 0;
}
}
h3 {
color: $grey-55;
font-size: $font-small;
margin-bottom: $unit;
}
select {
background-color: $grey-90;
}
}
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit-2x);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
}
}
}

View file

@ -1,307 +0,0 @@
// Core dependencies
import React, {
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import classNames from 'classnames'
// UI dependencies
import {
Dialog,
DialogClose,
DialogTitle,
DialogTrigger,
} from '~components/Dialog'
import DialogContent from '~components/DialogContent'
import Button from '~components/Button'
import SelectWithInput from '~components/SelectWithInput'
import AwakeningSelect from '~components/AwakeningSelect'
import RingSelect from '~components/RingSelect'
import Switch from '~components/Switch'
// Utilities
import api from '~utils/api'
import { appState } from '~utils/appState'
import { retrieveCookies } from '~utils/retrieveCookies'
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
// Data
const emptyExtendedMastery: ExtendedMastery = {
modifier: 0,
strength: 0,
}
// Styles and icons
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
// Types
import {
CharacterOverMastery,
ExtendedMastery,
GridCharacterObject,
} from '~types'
interface Props {
gridCharacter: GridCharacter
open: boolean
onOpenChange: (open: boolean) => void
updateCharacter: (object: GridCharacterObject) => Promise<any>
}
const CharacterModal = ({
gridCharacter,
children,
open: modalOpen,
onOpenChange,
updateCharacter,
}: PropsWithChildren<Props>) => {
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common')
// Cookies
const cookies = retrieveCookies()
// UI state
const [open, setOpen] = useState(false)
const [formValid, setFormValid] = useState(false)
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const footerRef = React.createRef<HTMLDivElement>()
// Classes
const headerClasses = classNames({
DialogHeader: true,
Short: true,
})
// Callbacks and Hooks
useEffect(() => {
setOpen(modalOpen)
}, [modalOpen])
// Character properties: Perpetuity
const [perpetuity, setPerpetuity] = useState(false)
// Character properties: Ring
const [rings, setRings] = useState<CharacterOverMastery>({
1: { ...emptyExtendedMastery, modifier: 1 },
2: { ...emptyExtendedMastery, modifier: 2 },
3: emptyExtendedMastery,
4: emptyExtendedMastery,
})
// Character properties: Earrings
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
// Character properties: Awakening
const [awakeningType, setAwakeningType] = useState(0)
const [awakeningLevel, setAwakeningLevel] = useState(0)
// Character properties: Transcendence
const [transcendenceStep, setTranscendenceStep] = useState(0)
// Hooks
useEffect(() => {
if (gridCharacter.aetherial_mastery) {
setEarring({
modifier: gridCharacter.aetherial_mastery.modifier,
strength: gridCharacter.aetherial_mastery.strength,
})
}
setAwakeningType(gridCharacter.awakening.type)
setAwakeningLevel(gridCharacter.awakening.level)
setPerpetuity(gridCharacter.perpetuity)
}, [gridCharacter])
// Prepare the GridWeaponObject to send to the server
function prepareObject() {
let object: GridCharacterObject = {
character: {
ring1: {
modifier: rings[1].modifier,
strength: rings[1].strength,
},
ring2: {
modifier: rings[2].modifier,
strength: rings[2].strength,
},
ring3: {
modifier: rings[3].modifier,
strength: rings[3].strength,
},
ring4: {
modifier: rings[4].modifier,
strength: rings[4].strength,
},
earring: {
modifier: earring.modifier,
strength: earring.strength,
},
awakening: {
type: awakeningType,
level: awakeningLevel,
},
transcendence_step: transcendenceStep,
perpetuity: perpetuity,
},
}
return object
}
// Methods: UI state management
function handleOpenChange(open: boolean) {
setOpen(open)
onOpenChange(open)
}
// Methods: Receive data from components
function receiveRingValues(overMastery: CharacterOverMastery) {
setRings(overMastery)
}
function receiveEarringValues(
earringModifier: number,
earringStrength: number
) {
setEarring({
modifier: earringModifier,
strength: earringStrength,
})
}
function handleCheckedChange(checked: boolean) {
setPerpetuity(checked)
}
async function handleUpdateCharacter() {
await updateCharacter(prepareObject())
setOpen(false)
if (onOpenChange) onOpenChange(false)
}
function receiveAwakeningValues(type: number, level: number) {
setAwakeningType(type)
setAwakeningLevel(level)
}
function receiveValidity(isValid: boolean) {
setFormValid(isValid)
}
const ringSelect = () => {
return (
<section>
<h3>{t('modals.characters.subtitles.ring')}</h3>
<RingSelect
gridCharacter={gridCharacter}
sendValues={receiveRingValues}
/>
</section>
)
}
const earringSelect = () => {
const earringData = elementalizeAetherialMastery(gridCharacter)
return (
<section>
<h3>{t('modals.characters.subtitles.earring')}</h3>
<SelectWithInput
object="earring"
dataSet={earringData}
selectValue={earring.modifier ? earring.modifier : 0}
inputValue={earring.strength ? earring.strength : 0}
sendValidity={receiveValidity}
sendValues={receiveEarringValues}
/>
</section>
)
}
const awakeningSelect = () => {
return (
<section>
<h3>{t('modals.characters.subtitles.awakening')}</h3>
<AwakeningSelect
object="character"
type={awakeningType}
level={awakeningLevel}
sendValidity={receiveValidity}
sendValues={receiveAwakeningValues}
/>
</section>
)
}
const perpetuitySwitch = () => {
return (
<section className="inline">
<h3>{t('modals.characters.subtitles.permanent')}</h3>
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
</section>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="Character"
headerref={headerRef}
footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>
<div className={headerClasses} ref={headerRef}>
<img
alt={gridCharacter.object.name[locale]}
className="DialogImage"
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`}
/>
<div className="DialogTop">
<DialogTitle className="SubTitle">
{t('modals.characters.title')}
</DialogTitle>
<DialogTitle className="DialogTitle">
{gridCharacter.object.name[locale]}
</DialogTitle>
</div>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<div className="mods">
{perpetuitySwitch()}
{ringSelect()}
{earringSelect()}
{awakeningSelect()}
</div>
<div className="DialogFooter" ref={footerRef}>
<Button
contained={true}
onClick={handleUpdateCharacter}
disabled={!formValid}
text={t('modals.characters.buttons.confirm')}
/>
</div>
</DialogContent>
</Dialog>
)
}
export default CharacterModal

View file

@ -1,52 +0,0 @@
import React from 'react'
import { useRouter } from 'next/router'
import UncapIndicator from '~components/UncapIndicator'
import WeaponLabelIcon from '~components/WeaponLabelIcon'
import './index.scss'
interface Props {
data: Character
onClick: () => void
}
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const CharacterResult = (props: Props) => {
const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const character = props.data
const characterUrl = () => {
let url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01.jpg`
if (character.granblue_id === '3030182000') {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01_01.jpg`
}
return url
}
return (
<li className="CharacterResult" onClick={props.onClick}>
<img alt={character.name[locale]} src={characterUrl()} />
<div className="Info">
<h5>{character.name[locale]}</h5>
<UncapIndicator
type="character"
flb={character.uncap.flb}
ulb={character.uncap.ulb}
special={character.special}
/>
<div className="tags">
<WeaponLabelIcon labelType={Element[character.element]} />
</div>
</div>
</li>
)
}
export default CharacterResult

View file

@ -1,11 +0,0 @@
.Content.Version {
.Contents {
margin-bottom: $unit-3x;
}
.Notes h4 {
font-weight: $medium;
font-size: $font-regular;
margin-bottom: $unit-2x;
}
}

View file

@ -1,143 +0,0 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import ChangelogUnit from '~components/ChangelogUnit'
import './index.scss'
interface UpdateObject {
character?: string[]
summon?: string[]
weapon?: string[]
}
interface Props {
version: string
dateString: string
event: string
newItems?: UpdateObject
uncappedItems?: UpdateObject
numNotes: number
}
const ContentUpdate = ({
version,
dateString,
event,
newItems,
uncappedItems,
numNotes,
}: Props) => {
const { t: updates } = useTranslation('updates')
const date = new Date(dateString)
function newItemElements(key: 'character' | 'weapon' | 'summon') {
let elements: React.ReactNode[] = []
if (newItems && newItems[key]) {
const items = newItems[key]
elements = items
? items.map((id, i) => {
return <ChangelogUnit id={id} type={key} key={`${key}-${i}`} />
})
: []
}
return elements
}
function newItemSection(key: 'character' | 'weapon' | 'summon') {
let section: React.ReactNode = ''
if (newItems && newItems[key]) {
const items = newItems[key]
section =
items && items.length > 0 ? (
<section className={`${key}s`}>
<h4>{updates(`labels.${key}s`)}</h4>
<div className="items">{newItemElements(key)}</div>
</section>
) : (
''
)
}
return section
}
function uncapItemElements(key: 'character' | 'weapon' | 'summon') {
let elements: React.ReactNode[] = []
if (uncappedItems && uncappedItems[key]) {
const items = uncappedItems[key]
elements = items
? items.map((id) => {
return key === 'character' ? (
<ChangelogUnit id={id} type={key} image="03" />
) : (
<ChangelogUnit id={id} type={key} />
)
})
: []
}
return elements
}
function uncapItemSection(key: 'character' | 'weapon' | 'summon') {
let section: React.ReactNode = ''
if (uncappedItems && uncappedItems[key]) {
const items = uncappedItems[key]
section =
items && items.length > 0 ? (
<section className={`${key}s`}>
<h4>{updates(`labels.uncaps.${key}s`)}</h4>
<div className="items">{uncapItemElements(key)}</div>
</section>
) : (
''
)
}
return section
}
return (
<section className="Content Version" data-version={version}>
<div className="Header">
<h3>{`${updates('events.date', {
year: date.getFullYear(),
month: `${date.getMonth() + 1}`.padStart(2, '0'),
})} ${updates(event)}`}</h3>
<time>{dateString}</time>
</div>
<div className="Contents">
{newItemSection('character')}
{uncapItemSection('character')}
{newItemSection('weapon')}
{uncapItemSection('weapon')}
{newItemSection('summon')}
{uncapItemSection('summon')}
</div>
{numNotes > 0 ? (
<div className="Notes">
<section>
<h4>{updates('labels.updates')}</h4>
<ul className="Bare Contents">
{[...Array(numNotes)].map((e, i) => (
<li key={`${version}-${i}`}>
{updates(`versions.${version}.features.${i}`)}
</li>
))}
</ul>
</section>
</div>
) : (
''
)}
</section>
)
}
ContentUpdate.defaultProps = {
numNotes: 0,
}
export default ContentUpdate

View file

@ -1,287 +0,0 @@
.Dialog {
position: fixed;
box-sizing: border-box;
background: none;
border: 0;
inset: 0;
display: grid;
padding: 0;
place-items: center;
min-height: 100vh;
min-width: 100vw;
overflow-y: auto;
color: inherit;
z-index: 40;
.DialogContent {
$multiplier: 4;
// animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal
// none running openModalDesktop;
background: var(--dialog-bg);
border-radius: $card-corner;
box-sizing: border-box;
border: 0.5px solid rgba(0, 0, 0, 0.18);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
gap: $unit * $multiplier;
height: auto;
min-width: $unit * 48;
// min-height: $unit-12x;
overflow-y: scroll;
// height: 80vh;
max-height: 80vh;
min-width: 580px;
max-width: 42vw;
// padding: $unit * $multiplier;
position: relative;
a:hover {
text-decoration: underline;
}
@include breakpoint(phone) {
// animation: slideUp;
// animation-duration: 3s;
// animation-fill-mode: forwards;
// animation-play-state: running;
// animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
min-width: inherit;
min-height: 90vh;
transform: initial;
left: 0;
right: 0;
top: 5vh;
height: auto;
width: 100%;
}
.Scrollable {
overflow-y: auto;
}
.DialogHeader {
background: var(--dialog-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0);
border-bottom: 1px solid rgba(0, 0, 0, 0);
display: flex;
align-items: center;
gap: $unit-2x;
justify-content: space-between;
padding: $unit-4x ($unit * $multiplier);
position: sticky;
top: 0;
z-index: 10;
&.Short {
padding-top: $unit-3x;
padding-bottom: $unit-3x;
}
.left {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit;
p {
font-size: $font-small;
line-height: 1.25;
}
}
.DialogImage {
border-radius: $input-corner;
width: $unit-10x;
}
}
.DialogClose {
background: transparent;
border: none;
&:hover {
cursor: pointer;
svg {
fill: $error;
}
}
svg {
fill: $grey-50;
float: right;
height: 24px;
width: 24px;
}
}
.DialogTitle {
color: var(--text-primary);
font-size: $font-xlarge;
h1 {
color: var(--text-primary);
font-size: $font-xlarge;
font-weight: $medium;
text-align: left;
}
}
.DialogTop {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: calc($unit / 2);
.SubTitle {
color: var(--text-secondary);
font-size: $font-small;
font-weight: $medium;
}
}
.DialogDescription {
color: var(--text-secondary);
flex-grow: 1;
}
.DialogFooter {
align-items: flex-end;
background: var(--dialog-bg);
bottom: 0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.16);
border-top: 1px solid rgba(0, 0, 0, 0.24);
display: flex;
flex-direction: column;
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
position: sticky;
.Buttons {
display: flex;
gap: $unit;
&.Span {
width: 100%;
.Button {
width: 100%;
}
}
}
}
.actions {
display: flex;
justify-content: flex-end;
width: 100%;
}
&.Conflict {
$weapon-diameter: 14rem;
.Content {
display: flex;
flex-direction: column;
gap: $unit-4x;
padding: $unit-4x $unit-4x $unit-2x $unit-4x;
& > p {
font-size: $font-regular;
line-height: 1.4;
strong {
font-weight: $bold;
}
&:lang(ja) {
line-height: 1.4;
}
}
}
.weapon,
.character {
display: flex;
flex-direction: column;
gap: $unit;
text-align: center;
width: $weapon-diameter;
font-weight: $medium;
img {
border-radius: 1rem;
width: $weapon-diameter;
height: auto;
}
span {
line-height: 1.3;
}
}
.Diagram {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: flex-start;
&.CharacterDiagram {
align-items: center;
}
ul {
align-items: center;
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.wrapper {
display: flex;
justify-content: center;
width: 100%;
}
.arrow {
align-items: center;
color: $grey-55;
display: flex;
font-size: 4rem;
text-align: center;
height: $weapon-diameter;
justify-content: center;
}
}
footer {
display: flex;
flex-direction: row;
gap: $unit;
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit * 2);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
&:not(.btn-disabled) {
background: $grey-90;
color: $grey-50;
&:hover {
background: $grey-80;
}
}
}
}
}
}
}

View file

@ -1,193 +0,0 @@
.Menu {
transform-origin: --radix-dropdown-menu-content-transform-origin;
background: var(--menu-bg);
border-radius: 6px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
box-sizing: border-box;
width: 30vw;
max-width: 180px;
margin: 0 $unit-2x;
z-index: 15;
@include breakpoint(phone) {
min-width: 50vw;
}
}
.MenuLabel {
color: var(--text-tertiary);
padding: $unit * 1.5 $unit * 1.5;
font-size: $font-small;
font-weight: $medium;
}
.MenuItem {
color: var(--text-tertiary);
font-weight: $normal;
@include breakpoint(phone) {
cursor: pointer;
}
&:hover:not(.disabled) {
background: var(--menu-bg-item-hover);
color: var(--text-primary);
cursor: pointer;
a {
color: var(--text-primary);
&:hover {
text-decoration: none;
}
&:visited {
color: var(--text-primary);
}
}
@include breakpoint(phone) {
background: inherit;
color: inherit;
cursor: default;
a {
color: inherit;
}
}
}
&.profile > div {
padding: 6px 12px;
}
&.language {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
padding-right: $unit;
span {
flex-grow: 1;
}
.Switch {
$height: 24px;
background: $grey-60;
border-radius: calc($height / 2);
border: none;
position: relative;
width: 44px;
height: $height;
&:hover {
cursor: pointer;
}
.Thumb {
$diameter: 18px;
background: $grey-100;
border-radius: calc($diameter / 2);
display: block;
height: $diameter;
width: $diameter;
position: absolute;
top: 3px;
left: 3px;
z-index: 3;
&:hover {
cursor: pointer;
}
&[data-state='checked'] {
background: $grey-100;
left: 23px;
}
}
.left,
.right {
color: $grey-100;
font-size: 10px;
font-weight: $bold;
position: absolute;
z-index: 2;
}
.left {
top: 6px;
left: 6px;
}
.right {
top: 6px;
right: 5px;
}
}
}
a {
color: $grey-50;
&:hover {
text-decoration: none;
}
&:visited {
color: $grey-50;
}
}
& > a,
& > span {
display: block;
padding: 12px 12px;
}
& > div {
align-items: center;
display: flex;
flex-direction: row;
padding: 10px 12px;
&:hover {
i.tag {
background: var(--tag-bg);
color: var(--tag-text);
}
}
span {
flex-grow: 1;
}
img {
$diameter: 32px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
}
}
.MenuGroup {
border-bottom: 1px solid var(--menu-separator);
&:first-child .MenuItem:first-child:hover {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
&:last-child .MenuItem:last-child:hover {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
&:last-child {
border-bottom: none;
}
}

View file

@ -1,4 +1,4 @@
.ToggleGroup { .group {
$height: 36px; $height: 36px;
background-color: var(--toggle-bg); background-color: var(--toggle-bg);
@ -8,15 +8,28 @@
height: $height; height: $height;
gap: calc($unit / 4); gap: calc($unit / 4);
padding: calc($unit / 2); padding: calc($unit / 2);
flex-wrap: wrap;
.ToggleItem { @include breakpoint(phone) {
border-radius: $unit-2x;
height: auto;
}
.item {
background: var(--toggle-bg); background: var(--toggle-bg);
border: none; border: none;
border-radius: 18px; border-radius: 18px;
color: var(--input-secondary); color: var(--input-secondary);
flex-grow: 1; flex-grow: 1;
font-size: $font-regular; font-size: $font-regular;
padding: ($unit) $unit * 2; padding-top: $unit;
padding-bottom: $unit;
@include breakpoint(phone) {
border-radius: $card-corner;
padding-left: $unit-2x;
padding-right: $unit-2x;
}
&.ja { &.ja {
padding-top: 6px; padding-top: 6px;
@ -34,32 +47,32 @@
&.fire { &.fire {
background: var(--fire-bg); background: var(--fire-bg);
color: var(--fire-text); color: var(--fire-text-bg);
} }
&.water { &.water {
background: var(--water-bg); background: var(--water-bg);
color: var(--water-text); color: var(--water-text-bg);
} }
&.earth { &.earth {
background: var(--earth-bg); background: var(--earth-bg);
color: var(--earth-text); color: var(--earth-text-bg);
} }
&.wind { &.wind {
background: var(--wind-bg); background: var(--wind-bg);
color: var(--wind-text); color: var(--wind-text-bg);
} }
&.dark { &.dark {
background: var(--dark-bg); background: var(--dark-bg);
color: var(--dark-text); color: var(--dark-text-bg);
} }
&.light { &.light {
background: var(--light-bg); background: var(--light-bg);
color: var(--light-text); color: var(--light-text-bg);
} }
} }
} }

View file

@ -1,74 +1,113 @@
import React from 'react' 'use client'
import { useRouter } from 'next/router' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next' import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import classNames from 'classnames'
import * as ToggleGroup from '@radix-ui/react-toggle-group' import * as ToggleGroup from '@radix-ui/react-toggle-group'
import styles from './index.module.scss'
import './index.scss'
interface Props { interface Props {
currentElement: number currentElement: number
sendValue: (value: string) => void sendValue: (value: number) => void
} }
const ElementToggle = (props: Props) => { const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
const router = useRouter() // Localization
const { t } = useTranslation('common') const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const t = useTranslations('common')
// State: Component
const [element, setElement] = useState(currentElement)
// Methods: Handlers
const handleElementChange = (value: string) => {
const newElement = parseInt(value)
setElement(newElement)
sendValue(newElement)
}
// Methods: Rendering
return ( return (
<ToggleGroup.Root <ToggleGroup.Root
className="ToggleGroup" className={styles.group}
type="single" type="single"
defaultValue={`${props.currentElement}`} value={`${element}`}
aria-label="Element" aria-label="Element"
onValueChange={props.sendValue} onValueChange={handleElementChange}
> >
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem ${locale}`} className={classNames({
[styles.item]: true,
[styles[`${locale}`]]: true,
})}
value="0" value="0"
aria-label="null" aria-label="null"
> >
{t('elements.null')} {t('elements.null')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem wind ${locale}`} className={classNames({
[styles.item]: true,
[styles.wind]: true,
[styles[`${locale}`]]: true,
})}
value="1" value="1"
aria-label="wind" aria-label="wind"
> >
{t('elements.wind')} {t('elements.wind')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem fire ${locale}`} className={classNames({
[styles.item]: true,
[styles.fire]: true,
[styles[`${locale}`]]: true,
})}
value="2" value="2"
aria-label="fire" aria-label="fire"
> >
{t('elements.fire')} {t('elements.fire')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem water ${locale}`} className={classNames({
[styles.item]: true,
[styles.water]: true,
[styles[`${locale}`]]: true,
})}
value="3" value="3"
aria-label="water" aria-label="water"
> >
{t('elements.water')} {t('elements.water')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem earth ${locale}`} className={classNames({
[styles.item]: true,
[styles.earth]: true,
[styles[`${locale}`]]: true,
})}
value="4" value="4"
aria-label="earth" aria-label="earth"
> >
{t('elements.earth')} {t('elements.earth')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem dark ${locale}`} className={classNames({
[styles.item]: true,
[styles.dark]: true,
[styles[`${locale}`]]: true,
})}
value="5" value="5"
aria-label="dark" aria-label="dark"
> >
{t('elements.dark')} {t('elements.dark')}
</ToggleGroup.Item> </ToggleGroup.Item>
<ToggleGroup.Item <ToggleGroup.Item
className={`ToggleItem light ${locale}`} className={classNames({
[styles.item]: true,
[styles.light]: true,
[styles[`${locale}`]]: true,
})}
value="6" value="6"
aria-label="light" aria-label="light"
> >

View file

@ -1,4 +1,4 @@
section.Error { .error {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -9,14 +9,13 @@ section.Error {
height: 60vh; height: 60vh;
text-align: center; text-align: center;
.Code { .code {
color: var(--text-secondary); color: var(--text-secondary);
font-size: $font-tiny; font-size: $font-tiny;
font-weight: $bold; font-weight: $bold;
} }
.Button { p {
margin-top: $unit-2x; margin-bottom: $unit-4x;
width: fit-content;
} }
} }

View file

@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useTranslation } from 'next-i18next' import { useTranslations } from 'next-intl'
import Button from '~components/Button' import Button from '~components/common/Button'
import { ResponseStatus } from '~types' import { ResponseStatus } from '~types'
import './index.scss' import styles from './index.module.scss'
interface Props { interface Props {
status: ResponseStatus status: ResponseStatus
@ -13,7 +13,7 @@ interface Props {
const ErrorSection = ({ status }: Props) => { const ErrorSection = ({ status }: Props) => {
// Import translations // Import translations
const { t } = useTranslation('common') const t = useTranslations('common')
const [statusText, setStatusText] = useState('') const [statusText, setStatusText] = useState('')
@ -24,7 +24,7 @@ const ErrorSection = ({ status }: Props) => {
const errorBody = () => { const errorBody = () => {
return ( return (
<> <>
<div className="Code">{status.code}</div> <div className={styles.code}>{status.code}</div>
<h1>{t(`errors.${statusText}.title`)}</h1> <h1>{t(`errors.${statusText}.title`)}</h1>
<p>{t(`errors.${statusText}.description`)}</p> <p>{t(`errors.${statusText}.description`)}</p>
</> </>
@ -32,7 +32,7 @@ const ErrorSection = ({ status }: Props) => {
} }
return ( return (
<section className="Error"> <section className={styles.error}>
{errorBody()} {errorBody()}
{[401, 404].includes(status.code) ? ( {[401, 404].includes(status.code) ? (
<Link href="/new"> <Link href="/new">

View file

@ -1,17 +0,0 @@
.SelectSet {
display: flex;
flex-direction: row;
gap: $unit;
width: 100%;
.SelectTrigger.Left {
flex-grow: 1;
width: 100%;
}
.SelectTrigger.Right {
flex-grow: 0;
text-align: right;
min-width: 12rem;
}
}

View file

@ -1,59 +0,0 @@
.ExtraGrid.Weapons {
background: var(--extra-purple-bg);
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-columns: 1.42fr 3fr;
justify-content: center;
margin: 20px auto;
max-width: calc($grid-width + 20px);
padding: $unit-2x $unit-2x $unit-2x 0;
position: relative;
left: $unit;
@include breakpoint(tablet) {
left: auto;
max-width: auto;
width: 100%;
}
@include breakpoint(phone) {
display: flex;
gap: $unit-2x;
padding: $unit-2x;
flex-direction: column;
}
& > span {
color: var(--extra-purple-text);
display: flex;
align-items: center;
flex-grow: 1;
justify-content: center;
line-height: 1.2;
font-weight: 500;
text-align: center;
}
#ExtraWeapons {
display: grid;
gap: $unit-3x;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include breakpoint(tablet) {
gap: $unit-2x;
}
@include breakpoint(phone) {
gap: $unit;
}
}
.WeaponUnit .WeaponImage {
background: var(--extra-purple-card-bg);
}
.WeaponUnit .WeaponImage .icon svg {
fill: var(--extra-purple-secondary);
}
}

View file

@ -1,48 +0,0 @@
import React from 'react'
import { useTranslation } from 'next-i18next'
import WeaponUnit from '~components/WeaponUnit'
import type { SearchableObject } from '~types'
import './index.scss'
// Props
interface Props {
grid: GridArray<GridWeapon>
editable: boolean
found?: boolean
offset: number
removeWeapon: (id: string) => void
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
const ExtraWeapons = (props: Props) => {
const numWeapons: number = 3
const { t } = useTranslation('common')
return (
<div className="ExtraGrid Weapons">
<span>{t('extra_weapons')}</span>
<ul id="ExtraWeapons">
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`grid_unit_${i}`}>
<WeaponUnit
editable={i < 2 ? props.editable : false}
position={props.offset + i}
unitType={1}
gridWeapon={props.grid[props.offset + i]}
removeWeapon={props.removeWeapon}
updateObject={props.updateObject}
updateUncap={props.updateUncap}
/>
</li>
)
})}
</ul>
</div>
)
}
export default ExtraWeapons

View file

@ -1,109 +0,0 @@
.FilterBar {
align-items: center;
background: var(--bar-bg);
border-radius: $card-corner;
box-sizing: border-box;
display: flex;
flex-direction: row;
gap: $unit-2x;
margin: 0 auto;
margin-top: 7px; // Line up with HeaderMenu
padding: $unit * 2;
position: sticky;
transition: box-shadow 0.24s ease-in-out;
top: $unit * 4;
width: 100%;
max-width: 996px;
min-height: 80px;
@include breakpoint(tablet) {
position: static;
flex-direction: column;
width: 100%;
}
@include breakpoint(phone) {
min-height: auto;
}
.Filters {
display: flex;
box-sizing: border-box;
flex-direction: row;
flex-grow: 1;
gap: $unit;
width: auto;
@include breakpoint(tablet) {
flex-direction: column;
width: 100%;
}
}
&.shadow {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
}
h1 {
color: var(--text-primary);
font-size: $font-regular;
font-weight: $normal;
flex-grow: 1;
text-align: left;
}
select,
.SelectTrigger {
// background: url("/icons/Arrow.svg"), $grey-90;
// background-repeat: no-repeat;
// background-position-y: center;
// background-position-x: 95%;
// background-size: $unit * 1.5;
background-color: var(--select-contained-bg);
color: $grey-55;
font-size: $font-small;
margin: 0;
max-width: 200px;
&:hover {
background-color: var(--select-contained-bg-hover);
}
@include breakpoint(tablet) {
width: 100%;
max-width: inherit;
text-align: center;
}
}
.SelectTrigger {
width: 100%;
span {
font-size: $font-small;
}
}
.UserInfo {
align-items: center;
display: flex;
flex-direction: row;
flex-grow: 1;
gap: $unit * 1.5;
img {
$diameter: $unit * 6;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
&.gran {
background-color: #cee7fe;
}
&.djeeta {
background-color: #ffe1fe;
}
}
}
}

View file

@ -1,147 +0,0 @@
import React, { useState } from 'react'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import RaidDropdown from '~components/RaidDropdown'
import './index.scss'
import Select from '~components/Select'
import SelectItem from '~components/SelectItem'
interface Props {
children: React.ReactNode
scrolled: boolean
element?: number
raidSlug?: string
recency?: number
onFilter: ({
element,
raidSlug,
recency,
}: {
element?: number
raidSlug?: string
recency?: number
}) => void
}
const FilterBar = (props: Props) => {
// Set up translation
const { t } = useTranslation('common')
const [recencyOpen, setRecencyOpen] = useState(false)
const [elementOpen, setElementOpen] = useState(false)
// Set up classes object for showing shadow on scroll
const classes = classNames({
FilterBar: true,
shadow: props.scrolled,
})
function openElementSelect() {
setElementOpen(!elementOpen)
}
function openRecencySelect() {
setRecencyOpen(!recencyOpen)
}
function elementSelectChanged(value: string) {
const elementValue = parseInt(value)
props.onFilter({ element: elementValue })
}
function recencySelectChanged(value: string) {
const recencyValue = parseInt(value)
props.onFilter({ recency: recencyValue })
}
function raidSelectChanged(slug?: string) {
props.onFilter({ raidSlug: slug })
}
function onSelectChange(name: 'element' | 'recency') {
setElementOpen(name === 'element' ? !elementOpen : false)
setRecencyOpen(name === 'recency' ? !recencyOpen : false)
}
return (
<div className={classes}>
{props.children}
<div className="Filters">
<Select
value={`${props.element}`}
open={elementOpen}
onOpenChange={() => onSelectChange('element')}
onValueChange={elementSelectChanged}
onClick={openElementSelect}
>
<SelectItem data-element="all" key={-1} value={-1}>
{t('elements.full.all')}
</SelectItem>
<SelectItem data-element="null" key={0} value={0}>
{t('elements.full.null')}
</SelectItem>
<SelectItem data-element="wind" key={1} value={1}>
{t('elements.full.wind')}
</SelectItem>
<SelectItem data-element="fire" key={2} value={2}>
{t('elements.full.fire')}
</SelectItem>
<SelectItem data-element="water" key={3} value={3}>
{t('elements.full.water')}
</SelectItem>
<SelectItem data-element="earth" key={4} value={4}>
{t('elements.full.earth')}
</SelectItem>
<SelectItem data-element="dark" key={5} value={5}>
{t('elements.full.dark')}
</SelectItem>
<SelectItem data-element="light" key={6} value={6}>
{t('elements.full.light')}
</SelectItem>
</Select>
<RaidDropdown
currentRaid={props.raidSlug}
defaultRaid="all"
showAllRaidsOption={true}
onChange={raidSelectChanged}
/>
<Select
value={`${props.recency}`}
trigger={'All time'}
open={recencyOpen}
onOpenChange={() => onSelectChange('recency')}
onValueChange={recencySelectChanged}
onClick={openRecencySelect}
>
<SelectItem key={-1} value={-1}>
{t('recency.all_time')}
</SelectItem>
<SelectItem key={86400} value={86400}>
{t('recency.last_day')}
</SelectItem>
<SelectItem key={604800} value={604800}>
{t('recency.last_week')}
</SelectItem>
<SelectItem key={2629746} value={2629746}>
{t('recency.last_month')}
</SelectItem>
<SelectItem key={7889238} value={7889238}>
{t('recency.last_3_months')}
</SelectItem>
<SelectItem key={15778476} value={15778476}>
{t('recency.last_6_months')}
</SelectItem>
<SelectItem key={31556952} value={31556952}>
{t('recency.last_year')}
</SelectItem>
</Select>
</div>
</div>
)
}
export default FilterBar

View file

@ -1,180 +0,0 @@
.GridRep {
aspect-ratio: 3/2;
border-radius: $card-corner;
box-sizing: border-box;
display: grid;
grid-template-rows: 1fr 1fr;
gap: $unit;
padding: $unit-2x;
min-width: 320px;
width: 100%;
&:hover {
background: var(--grid-rep-hover);
a {
text-decoration: none;
}
h2,
.Grid {
cursor: pointer;
}
.Grid .Weapon {
box-shadow: inset 0 0 0 1px var(--grid-border-color);
}
@include breakpoint(phone) {
background: inherit;
.Grid .Weapon {
box-shadow: none;
}
}
}
& > .Grid {
aspect-ratio: 2/1;
display: grid;
grid-template-columns: 1fr 3fr; /* left column takes up 1 fraction, right column takes up 3 fractions */
grid-gap: $unit; /* add a gap of 8px between grid items */
.Weapon {
background: var(--card-bg);
border-radius: 4px;
}
.Mainhand.Weapon {
aspect-ratio: 73/153;
display: grid;
grid-column: 1 / 2; /* spans one column */
}
.GridWeapons {
display: grid; /* make the right-images container a grid */
grid-template-columns: repeat(
3,
1fr
); /* create 3 columns, each taking up 1 fraction */
grid-template-rows: repeat(
3,
1fr
); /* create 3 rows, each taking up 1 fraction */
gap: $unit;
}
.Grid.Weapon {
aspect-ratio: 280 / 160;
display: grid;
}
.Mainhand.Weapon img[src*='jpg'],
.Grid.Weapon img[src*='jpg'] {
border-radius: 4px;
width: 100%;
}
}
.Details {
display: flex;
flex-direction: column;
gap: calc($unit / 2);
h2 {
color: var(--text-primary);
font-size: $font-regular;
overflow: hidden;
padding-bottom: 1px;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 258px; // Can we not do this?
&.empty {
color: var(--text-tertiary);
}
}
.top {
display: flex;
flex-direction: row;
gap: calc($unit / 2);
align-items: center;
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: calc($unit / 2);
}
button svg {
width: 14px;
height: 14px;
}
}
.bottom {
display: flex;
flex-direction: row;
a.user:hover {
color: var(--link-text-hover);
}
}
.Properties,
.user {
flex-grow: 1;
}
.user,
.raid,
time {
color: $grey-55;
font-size: $font-small;
}
.Properties {
.full_auto {
color: var(--full-auto-label-text);
}
}
.raid {
color: var(--text-primary);
margin-bottom: calc($unit / 2);
&.empty {
color: var(--text-tertiary);
}
}
.user {
display: flex;
gap: calc($unit / 2);
align-items: center;
img,
.no-user {
$diameter: 18px;
border-radius: calc($diameter / 2);
height: $diameter;
width: $diameter;
}
img.gran {
background-color: #cee7fe;
}
img.djeeta {
background-color: #ffe1fe;
}
.no-user {
background: $grey-80;
}
}
}
}

View file

@ -1,259 +0,0 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import classNames from 'classnames'
import 'fix-date'
import { accountState } from '~utils/accountState'
import { formatTimeAgo } from '~utils/timeAgo'
import Button from '~components/Button'
import SaveIcon from '~public/icons/Save.svg'
import './index.scss'
interface Props {
shortcode: string
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
fullAuto: boolean
favorited: boolean
createdAt: Date
displayUser?: boolean | false
onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void
}
const GridRep = (props: Props) => {
const numWeapons: number = 9
const { account } = useSnapshot(accountState)
const router = useRouter()
const { t } = useTranslation('common')
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [grid, setGrid] = useState<GridArray<GridWeapon>>({})
const titleClass = classNames({
empty: !props.name,
})
const raidClass = classNames({
raid: true,
empty: !props.raid,
})
const userClass = classNames({
user: true,
empty: !props.user,
})
useEffect(() => {
const newWeapons = Array(numWeapons)
const gridWeapons = Array(numWeapons)
for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1) setMainhand(value.object)
else if (!value.mainhand && value.position != null) {
newWeapons[value.position] = value.object
gridWeapons[value.position] = value
}
}
setWeapons(newWeapons)
setGrid(gridWeapons)
}, [props.grid])
function navigate() {
props.onClick(props.shortcode)
}
function generateMainhandImage() {
let url = ''
if (mainhand) {
const weapon = Object.values(props.grid).find(
(w) => w && w.object.id === mainhand.id
)
if (mainhand.element == 0 && weapon && weapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}_${weapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}.jpg`
}
}
return mainhand && props.grid[0] ? (
<img alt={mainhand.name[locale]} src={url} />
) : (
''
)
}
function generateGridImage(position: number) {
let url = ''
const weapon = weapons[position]
const gridWeapon = grid[position]
if (weapon && gridWeapon) {
if (weapon.element == 0 && gridWeapon.element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
}
}
return weapons[position] ? (
<img alt={weapons[position]?.name[locale]} src={url} />
) : (
''
)
}
function sendSaveData() {
if (props.onSave) props.onSave(props.id, props.favorited)
}
const userImage = () => {
if (props.user && props.user.avatar) {
return (
<img
alt={props.user.avatar.picture}
className={`profile ${props.user.avatar.element}`}
srcSet={`/profile/${props.user.avatar.picture}.png,
/profile/${props.user.avatar.picture}@2x.png 2x`}
src={`/profile/${props.user.avatar.picture}.png`}
/>
)
} else
return (
<img
alt={t('no_user')}
className={`profile anonymous`}
srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`}
src={`/profile/npc.png`}
/>
)
}
const linkedAttribution = () => (
<Link href={`/${props.user ? props.user.username : '#'}`}>
<span className={userClass}>
{userImage()}
{props.user ? props.user.username : t('no_user')}
</span>
</Link>
)
const unlinkedAttribution = () => (
<div className={userClass}>
{userImage()}
{props.user ? props.user.username : t('no_user')}
</div>
)
const details = (
<div className="Details">
<h2 className={titleClass}>{props.name ? props.name : t('no_title')}</h2>
<div className="bottom">
<div className="Properties">
<span className={raidClass}>
{props.raid ? props.raid.name[locale] : t('no_raid')}
</span>
{props.fullAuto ? (
<span className="full_auto">
{` · ${t('party.details.labels.full_auto')}`}
</span>
) : (
''
)}
</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
{formatTimeAgo(props.createdAt, locale)}
</time>
</div>
</div>
)
const detailsWithUsername = (
<div className="Details">
<div className="top">
<div className="info">
<h2 className={titleClass}>
{props.name ? props.name : t('no_title')}
</h2>
<div className="Properties">
<span className={raidClass}>
{props.raid ? props.raid.name[locale] : t('no_raid')}
</span>
{props.fullAuto ? (
<span className="full_auto">
{` · ${t('party.details.labels.full_auto')}`}
</span>
) : (
''
)}
</div>
</div>
{account.authorized &&
((props.user && account.user && account.user.id !== props.user.id) ||
!props.user) ? (
<Link href="#">
<Button
className="Save"
leftAccessoryIcon={<SaveIcon className="stroke" />}
active={props.favorited}
contained={true}
buttonSize="small"
onClick={sendSaveData}
/>
</Link>
) : (
''
)}
</div>
<div className="bottom">
{props.user ? linkedAttribution() : unlinkedAttribution()}
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
{formatTimeAgo(props.createdAt, locale)}
</time>
</div>
</div>
)
return (
<Link href={`/p/${props.shortcode}`}>
<a className="GridRep">
{props.displayUser ? detailsWithUsername : details}
<div className="Grid">
<div className="Mainhand Weapon">{generateMainhandImage()}</div>
<ul className="GridWeapons">
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`${props.shortcode}-${i}`} className="Grid Weapon">
{generateGridImage(i)}
</li>
)
})}
</ul>
</div>
</a>
</Link>
)
}
export default GridRep

View file

@ -1,18 +0,0 @@
import classNames from 'classnames'
import React from 'react'
import './index.scss'
interface Props {
children: React.ReactNode
}
const GridRepCollection = (props: Props) => {
const classes = classNames({
GridRepCollection: true,
})
return <div className={classes}>{props.children}</div>
}
export default GridRepCollection

View file

@ -1,4 +1,32 @@
#Header { .bahamut {
$negative-margin: $unit * -2;
align-items: center;
background: #2b4683;
box-sizing: border-box;
display: flex;
gap: $unit;
justify-content: center;
text-align: center;
font-weight: $bold;
padding: $unit-2x;
margin-top: $negative-margin;
margin-left: $negative-margin;
margin-right: $negative-margin;
margin-bottom: $unit-2x;
width: 100vw;
p {
color: white;
}
svg {
width: 1.2em;
fill: white;
}
}
.header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-bottom: $unit; margin-bottom: $unit;
@ -22,7 +50,7 @@
background: var(--placeholder-bg); background: var(--placeholder-bg);
} }
#DropdownWrapper { .dropdownWrapper {
display: inline-block; display: inline-block;
padding-bottom: $unit; padding-bottom: $unit;
@ -32,8 +60,6 @@
} }
&:hover { &:hover {
// padding-right: $unit-4x;
.Button { .Button {
background: var(--button-bg-hover); background: var(--button-bg-hover);
color: var(--button-text-hover); color: var(--button-text-hover);

View file

@ -1,86 +1,58 @@
import React, { useEffect, useState } from 'react' 'use client'
import { subscribe, useSnapshot } from 'valtio' import React, { useState } from 'react'
import { setCookie, deleteCookie } from 'cookies-next' import { deleteCookie, getCookie } from 'cookies-next'
import { useRouter } from 'next/router' import { useTranslations } from 'next-intl'
import { Trans, useTranslation } from 'next-i18next' import { useRouter } from '~/i18n/navigation'
import { useSnapshot } from 'valtio'
import classNames from 'classnames' import classNames from 'classnames'
import clonedeep from 'lodash.clonedeep' import clonedeep from 'lodash.clonedeep'
import Link from 'next/link' import Link from 'next/link'
import api from '~utils/api'
import { accountState, initialAccountState } from '~utils/accountState' import { accountState, initialAccountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState' import { appState, initialAppState } from '~utils/appState'
import { getLocalId } from '~utils/localId'
import { retrieveLocaleCookies } from '~utils/retrieveCookies'
import { setEditKey, storeEditKey } from '~utils/userToken'
import Alert from '~components/common/Alert'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuLabel, } from '~components/common/DropdownMenuContent'
} from '~components/DropdownMenuContent' import DropdownMenuGroup from '~components/common/DropdownMenuGroup'
import LoginModal from '~components/LoginModal' import DropdownMenuLabel from '~components/common/DropdownMenuLabel'
import SignupModal from '~components/SignupModal' import DropdownMenuItem from '~components/common/DropdownMenuItem'
import AccountModal from '~components/AccountModal' import LanguageSwitch from '~components/LanguageSwitch'
import Toast from '~components/Toast' import LoginModal from '~components/auth/LoginModal'
import Button from '~components/Button' import SignupModal from '~components/auth/SignupModal'
import Tooltip from '~components/Tooltip' import AccountModal from '~components/auth/AccountModal'
import * as Switch from '@radix-ui/react-switch' import Button from '~components/common/Button'
import Tooltip from '~components/common/Tooltip'
import ArrowIcon from '~public/icons/Arrow.svg' import BahamutIcon from '~public/icons/Bahamut.svg'
import LinkIcon from '~public/icons/Link.svg' import ChevronIcon from '~public/icons/Chevron.svg'
import MenuIcon from '~public/icons/Menu.svg' import MenuIcon from '~public/icons/Menu.svg'
import RemixIcon from '~public/icons/Remix.svg'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
import SaveIcon from '~public/icons/Save.svg'
import './index.scss' import styles from './index.module.scss'
const Header = () => { const Header = () => {
// Localization // Localization
const { t } = useTranslation('common') const t = useTranslations('common')
// Router
const router = useRouter() const router = useRouter()
const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' // Locale
const localeData = retrieveLocaleCookies() const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
// Subscribe to account state changes
const accountSnap = useSnapshot(accountState)
// State management // State management
const [copyToastOpen, setCopyToastOpen] = useState(false) const [alertOpen, setAlertOpen] = useState(false)
const [remixToastOpen, setRemixToastOpen] = useState(false)
const [loginModalOpen, setLoginModalOpen] = useState(false) const [loginModalOpen, setLoginModalOpen] = useState(false)
const [signupModalOpen, setSignupModalOpen] = useState(false) const [signupModalOpen, setSignupModalOpen] = useState(false)
const [settingsModalOpen, setSettingsModalOpen] = useState(false) const [settingsModalOpen, setSettingsModalOpen] = useState(false)
const [leftMenuOpen, setLeftMenuOpen] = useState(false) const [leftMenuOpen, setLeftMenuOpen] = useState(false)
const [rightMenuOpen, setRightMenuOpen] = useState(false) const [rightMenuOpen, setRightMenuOpen] = useState(false)
const [languageChecked, setLanguageChecked] = useState(false)
const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('')
// Snapshots
const { account } = useSnapshot(accountState)
const { party: partySnapshot } = useSnapshot(appState)
// Subscribe to app state to listen for party name and
// unsubscribe when component is unmounted
const unsubscribe = subscribe(appState, () => {
const newName =
appState.party && appState.party.name ? appState.party.name : ''
setName(newName)
})
useEffect(() => () => unsubscribe(), [])
// Hooks
useEffect(() => {
setLanguageChecked(localeData === 'ja' ? true : false)
}, [localeData])
// Methods: Event handlers (Buttons) // Methods: Event handlers (Buttons)
function handleLeftMenuButtonClicked() { function handleLeftMenuButtonClicked() {
@ -108,24 +80,6 @@ const Header = () => {
setRightMenuOpen(false) setRightMenuOpen(false)
} }
// Methods: Event handlers (Copy toast)
function handleCopyToastOpenChanged(open: boolean) {
setCopyToastOpen(open)
}
function handleCopyToastCloseClicked() {
setCopyToastOpen(false)
}
// Methods: Event handlers (Remix toasts)
function handleRemixToastOpenChanged(open: boolean) {
setRemixToastOpen(open)
}
function handleRemixToastCloseClicked() {
setRemixToastOpen(false)
}
// Methods: Actions // Methods: Actions
function handleNewTeam(event: React.MouseEvent) { function handleNewTeam(event: React.MouseEvent) {
event.preventDefault() event.preventDefault()
@ -133,32 +87,6 @@ const Header = () => {
closeRightMenu() closeRightMenu()
} }
function changeLanguage(value: boolean) {
const language = value ? 'ja' : 'en'
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 120)
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
router.push(router.asPath, undefined, { locale: language })
}
function copyToClipboard() {
const path = router.asPath.split('/')[1]
if (path === 'p') {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
setCopyToastOpen(true)
}
}
function logout() { function logout() {
// Close menu // Close menu
closeRightMenu() closeRightMenu()
@ -173,7 +101,7 @@ const Header = () => {
if (key !== 'language') accountState[key] = resetState[key] if (key !== 'language') accountState[key] = resetState[key]
}) })
router.reload() router.refresh()
return false return false
} }
@ -188,90 +116,11 @@ const Header = () => {
router.push('/new') router.push('/new')
} }
function remixTeam() { // Methods: Rendering
setOriginalName(partySnapshot.name ? partySnapshot.name : t('no_title'))
if (partySnapshot.shortcode) {
const body = getLocalId()
api
.remix({ shortcode: partySnapshot.shortcode, body: body })
.then((response) => {
const remix = response.data.party
// Store the edit key in local storage
if (remix.edit_key) {
storeEditKey(remix.id, remix.edit_key)
setEditKey(remix.id, remix.user)
}
router.push(`/p/${remix.shortcode}`)
setRemixToastOpen(true)
})
}
}
function toggleFavorite() {
if (partySnapshot.favorited) unsaveFavorite()
else saveFavorite()
}
function saveFavorite() {
if (partySnapshot.id)
api.saveTeam({ id: partySnapshot.id }).then((response) => {
if (response.status == 201) appState.party.favorited = true
})
else console.error('Failed to save team: No party ID')
}
function unsaveFavorite() {
if (partySnapshot.id)
api.unsaveTeam({ id: partySnapshot.id }).then((response) => {
if (response.status == 200) appState.party.favorited = false
})
else console.error('Failed to unsave team: No party ID')
}
// Rendering: Elements
const pageTitle = () => {
let title = ''
let hasAccessory = false
const path = router.asPath.split('/')[1]
if (path === 'p') {
hasAccessory = true
if (appState.party && appState.party.name) {
title = appState.party.name
} else {
title = t('no_title')
}
} else {
title = ''
}
return title !== '' ? (
<Tooltip content={t('tooltips.copy_url')}>
<Button
blended={true}
rightAccessoryIcon={
path === 'p' && hasAccessory ? (
<LinkIcon className="stroke" />
) : undefined
}
text={title}
onClick={copyToClipboard}
/>
</Tooltip>
) : (
''
)
}
const profileImage = () => { const profileImage = () => {
let image const user = accountSnap.account.user
if (accountSnap.account.authorized && user) {
const user = accountState.account.user return (
if (accountState.account.authorized && user) {
image = (
<img <img
alt={user.username} alt={user.username}
className={`profile ${user.avatar.element}`} className={`profile ${user.avatar.element}`}
@ -281,9 +130,9 @@ const Header = () => {
/> />
) )
} else { } else {
image = ( return (
<img <img
alt={t('no_user')} alt={t('header.anonymous')}
className={`profile anonymous`} className={`profile anonymous`}
srcSet={`/profile/npc.png, srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`} /profile/npc@2x.png 2x`}
@ -291,327 +140,266 @@ const Header = () => {
/> />
) )
} }
return image
} }
// Rendering: Buttons // Rendering: Buttons
const saveButton = () => { const newButton = (
return ( <Tooltip content={t('tooltips.new')}>
<Tooltip content={t('tooltips.save')}> <Button
<Button leftAccessoryIcon={<PlusIcon />}
leftAccessoryIcon={<SaveIcon />} className="New"
className={classNames({ blended={true}
Save: true, text={t('buttons.new')}
Saved: partySnapshot.favorited, onClick={newTeam}
})}
blended={true}
text={
partySnapshot.favorited ? t('buttons.saved') : t('buttons.save')
}
onClick={toggleFavorite}
/>
</Tooltip>
)
}
const newButton = () => {
return (
<Tooltip content={t('tooltips.new')}>
<Button
leftAccessoryIcon={<PlusIcon />}
className="New"
blended={true}
text={t('buttons.new')}
onClick={newTeam}
/>
</Tooltip>
)
}
const remixButton = () => {
return (
<Tooltip content={t('tooltips.remix')}>
<Button
leftAccessoryIcon={<RemixIcon />}
className="Remix"
blended={true}
text={t('buttons.remix')}
onClick={remixTeam}
/>
</Tooltip>
)
}
// Rendering: Toasts
const urlCopyToast = () => {
return (
<Toast
altText={t('toasts.copied')}
open={copyToastOpen}
duration={2400}
type="foreground"
content={t('toasts.copied')}
onOpenChange={handleCopyToastOpenChanged}
onCloseClick={handleCopyToastCloseClicked}
/> />
) </Tooltip>
} )
const remixToast = () => {
return (
<Toast
altText={t('toasts.remixed', { title: originalName })}
open={remixToastOpen}
duration={2400}
type="foreground"
content={
<Trans i18nKey="toasts.remixed">
You remixed <strong>{{ title: originalName }}</strong>
</Trans>
}
onOpenChange={handleRemixToastOpenChanged}
onCloseClick={handleRemixToastCloseClicked}
/>
)
}
// Rendering: Modals // Rendering: Modals
const settingsModal = () => { const logoutConfirmationAlert = (
const user = accountState.account.user <Alert
message={t('alert.confirm_logout')}
open={alertOpen}
primaryActionText="Log out"
primaryAction={logout}
cancelActionText="Nevermind"
cancelAction={() => setAlertOpen(false)}
/>
)
if (user) { const settingsModal = (
return ( <>
{accountSnap.account.user && (
<AccountModal <AccountModal
open={settingsModalOpen} open={settingsModalOpen}
username={user.username} username={accountSnap.account.user.username}
picture={user.avatar.picture} picture={accountSnap.account.user.avatar.picture}
gender={user.gender} gender={accountSnap.account.user.gender}
language={user.language} language={accountSnap.account.user.language}
theme={user.theme} theme={accountSnap.account.user.theme}
role={accountSnap.account.user.role}
bahamutMode={
accountSnap.account.user.role === 9
? accountSnap.account.user.bahamut
: false
}
onOpenChange={setSettingsModalOpen} onOpenChange={setSettingsModalOpen}
/> />
) )}
} </>
} )
const loginModal = () => { const loginModal = (
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
} )
const signupModal = () => { const signupModal = (
return ( <SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} /> )
)
}
// Rendering: Compositing // Rendering: Compositing
const left = () => { const authorizedLeftItems = (
return ( <>
<section> {accountSnap.account.user && (
<div id="DropdownWrapper"> <>
<DropdownMenu <DropdownMenuGroup>
open={leftMenuOpen} <DropdownMenuItem onClick={closeLeftMenu}>
onOpenChange={handleLeftMenuOpenChange} <Link
> href={`/${accountSnap.account.user.username}` || ''}
<DropdownMenuTrigger asChild> >
<Button <span>{t('menu.profile')}</span>
leftAccessoryIcon={<MenuIcon />} </Link>
className={classNames({ Active: leftMenuOpen })} </DropdownMenuItem>
blended={true} <DropdownMenuItem onClick={closeLeftMenu}>
onClick={handleLeftMenuButtonClicked} <Link href={`/saved` || ''}>{t('menu.saved')}</Link>
/> </DropdownMenuItem>
</DropdownMenuTrigger> </DropdownMenuGroup>
<DropdownMenuContent className="Left"> </>
{leftMenuItems()} )}
</DropdownMenuContent> </>
</DropdownMenu> )
</div> const leftMenuItems = (
{!appState.errorCode ? pageTitle() : ''} <>
</section> {accountSnap.account.authorized &&
) accountSnap.account.user &&
} authorizedLeftItems}
const right = () => { <DropdownMenuGroup>
return ( <DropdownMenuItem onClick={closeLeftMenu}>
<section> <Link href="/teams">{t('menu.teams')}</Link>
{router.route === '/p/[party]' && </DropdownMenuItem>
account.user && <DropdownMenuItem>
(!partySnapshot.user || partySnapshot.user.id !== account.user.id) && <div>
!appState.errorCode <span>{t('menu.guides')}</span>
? saveButton() <i className="tag">{t('coming_soon')}</i>
: ''} </div>
{router.route === '/p/[party]' && !appState.errorCode </DropdownMenuItem>
? remixButton() </DropdownMenuGroup>
: ''} <DropdownMenuGroup>
{newButton()} <DropdownMenuItem onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/about' : '/about'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.about')}
</a>
</DropdownMenuItem>
<DropdownMenuItem onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/updates' : '/updates'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.updates')}
</a>
</DropdownMenuItem>
<DropdownMenuItem onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.roadmap')}
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
const left = (
<section>
<div className={styles.dropdownWrapper}>
<DropdownMenu <DropdownMenu
open={rightMenuOpen} open={leftMenuOpen}
onOpenChange={handleRightMenuOpenChange} onOpenChange={handleLeftMenuOpenChange}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className={classNames({ Active: rightMenuOpen })} active={leftMenuOpen}
leftAccessoryIcon={profileImage()}
rightAccessoryIcon={<ArrowIcon />}
rightAccessoryClassName="Arrow"
onClick={handleRightMenuButtonClicked}
blended={true} blended={true}
leftAccessoryIcon={<MenuIcon />}
onClick={handleLeftMenuButtonClicked}
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="Right"> <DropdownMenuContent className="Left">
{rightMenuItems()} {leftMenuItems}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</section> </div>
) </section>
} )
const leftMenuItems = () => { const authorizedRightItems = (
return ( <>
<> {accountSnap.account.user && (
{accountState.account.authorized && accountState.account.user ? (
<>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<Link
href={`/${accountState.account.user.username}` || ''}
passHref
>
<span>{t('menu.profile')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : (
''
)}
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<Link href="/teams">{t('menu.teams')}</Link>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem">
<div>
<span>{t('menu.guides')}</span>
<i className="tag">{t('coming_soon')}</i>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/about' : '/about'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.about')}
</a>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/updates' : '/updates'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.updates')}
</a>
</DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={closeLeftMenu}>
<a
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
target="_blank"
rel="noreferrer"
>
{t('about.segmented_control.roadmap')}
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}
const rightMenuItems = () => {
let items
const account = accountState.account
if (account.authorized && account.user) {
items = (
<> <>
<DropdownMenuGroup className="MenuGroup"> <DropdownMenuGroup>
<DropdownMenuLabel className="MenuLabel"> <DropdownMenuLabel>
{account.user ? `@${account.user.username}` : t('no_user')} {`@${accountSnap.account.user.username}`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem className="MenuItem" onClick={closeRightMenu}> <DropdownMenuItem onClick={closeRightMenu}>
<Link href={`/${account.user.username}` || ''} passHref> <Link
href={`/${accountSnap.account.user.username}` || ''}
>
<span>{t('menu.profile')}</span> <span>{t('menu.profile')}</span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup className="MenuGroup"> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
className="MenuItem" className="MenuItem"
onClick={() => setSettingsModalOpen(true)} onClick={() => setSettingsModalOpen(true)}
> >
<span>{t('menu.settings')}</span> <span>{t('menu.settings')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="MenuItem" onClick={logout}> <DropdownMenuItem
onClick={() => setAlertOpen(true)}
destructive={true}
>
<span>{t('menu.logout')}</span> <span>{t('menu.logout')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</> </>
) )}
} else { </>
items = ( )
<>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem className="MenuItem language">
<span>{t('menu.language')}</span>
<Switch.Root
className="Switch"
onCheckedChange={changeLanguage}
checked={languageChecked}
>
<Switch.Thumb className="Thumb" />
<span className="left">JP</span>
<span className="right">EN</span>
</Switch.Root>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem
className="MenuItem"
onClick={() => setLoginModalOpen(true)}
>
<span>{t('menu.login')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="MenuItem"
onClick={() => setSignupModalOpen(true)}
>
<span>{t('menu.signup')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
}
return items const unauthorizedRightItems = (
} <>
<DropdownMenuGroup>
<DropdownMenuItem className="language">
<span>{t('menu.language')}</span>
<LanguageSwitch />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup className="MenuGroup">
<DropdownMenuItem
className="MenuItem"
onClick={() => setLoginModalOpen(true)}
>
<span>{t('menu.login')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="MenuItem"
onClick={() => setSignupModalOpen(true)}
>
<span>{t('menu.signup')}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)
const rightMenuItems = (
<>
{accountSnap.account.authorized && accountSnap.account.user
? authorizedRightItems
: unauthorizedRightItems}
</>
)
const right = (
<section>
{newButton}
<DropdownMenu
open={rightMenuOpen}
onOpenChange={handleRightMenuOpenChange}
>
<DropdownMenuTrigger asChild>
<Button
className={classNames({ Active: rightMenuOpen })}
leftAccessoryIcon={profileImage()}
rightAccessoryIcon={<ChevronIcon />}
rightAccessoryClassName="Arrow"
onClick={handleRightMenuButtonClicked}
blended={true}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="Right">
{rightMenuItems}
</DropdownMenuContent>
</DropdownMenu>
</section>
)
return ( return (
<nav id="Header"> <>
{left()} {accountSnap.account.user?.bahamut && (
{right()} <div className={styles.bahamut}>
{urlCopyToast()} <BahamutIcon />
{remixToast()} <p>Bahamut Mode is active</p>
{settingsModal()} </div>
{loginModal()} )}
{signupModal()} <nav className={styles.header}>
</nav> {left}
{right}
{logoutConfirmationAlert}
{settingsModal}
{loginModal}
{signupModal}
</nav>
</>
) )
} }

View file

@ -1,99 +0,0 @@
div[data-radix-popper-content-wrapper] {
z-index: 10 !important;
}
.HovercardContent {
animation: scaleIn $duration-zoom ease-out;
transform-origin: var(--radix-hover-card-content-transform-origin);
background: var(--dialog-bg);
border-radius: $card-corner;
color: var(--text-primary);
display: flex;
flex-direction: column;
gap: $unit-2x;
max-height: 30vh;
overflow-y: scroll;
padding: $unit-2x;
width: 300px;
.top {
display: flex;
flex-direction: column;
gap: calc($unit / 2);
.title {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit * 2;
h4 {
flex-grow: 1;
font-size: $font-medium;
line-height: 1.2;
min-width: 140px;
}
img {
height: auto;
width: 100px;
}
}
.subInfo {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit * 2;
.icons {
display: flex;
flex-direction: row;
flex-grow: 1;
gap: $unit;
}
.UncapIndicator {
min-width: 100px;
}
}
}
section {
h5 {
font-size: $font-small;
font-weight: $medium;
opacity: 0.7;
&.wind {
color: $wind-bg-20;
}
&.fire {
color: $fire-bg-20;
}
&.water {
color: $water-bg-20;
}
&.earth {
color: $earth-bg-20;
}
&.dark {
color: $dark-bg-10;
}
&.light {
color: $light-bg-20;
}
}
}
a.Button {
display: block;
padding: $unit * 1.5;
text-align: center;
}
}

View file

@ -0,0 +1,59 @@
.root {
display: flex;
flex-direction: column;
gap: calc($unit / 2);
.title {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit * 2;
h4 {
flex-grow: 1;
font-size: $font-medium;
line-height: 1.2;
min-width: 140px;
}
img {
height: auto;
width: 100px;
}
.image {
position: relative;
.perpetuity {
position: absolute;
background-image: url('/icons/perpetuity/filled.svg');
background-size: $unit-3x $unit-3x;
z-index: 20;
top: $unit-half * -1;
right: $unit-3x;
width: $unit-3x;
height: $unit-3x;
}
}
}
.subInfo {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: $unit-2x;
.icons {
display: flex;
flex-direction: row;
flex-grow: 0;
gap: $unit-half;
.proficiencies {
display: flex;
gap: $unit;
}
}
}
}

View file

@ -0,0 +1,193 @@
'use client'
import { getCookie } from 'cookies-next'
import UncapIndicator from '~components/uncap/UncapIndicator'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
import styles from './index.module.scss'
import classNames from 'classnames'
interface Props {
gridObject: GridCharacter | GridSummon | GridWeapon
object: Character | Summon | Weapon
type: 'character' | 'summon' | 'weapon'
}
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const Proficiency = [
'none',
'sword',
'dagger',
'axe',
'spear',
'bow',
'staff',
'fist',
'harp',
'gun',
'katana',
]
const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
const overlay = () => {
if (type === 'character') {
const gridCharacter = gridObject as GridCharacter
if (gridCharacter.perpetuity) return <i className={styles.perpetuity} />
} else if (type === 'summon') {
const gridSummon = gridObject as GridSummon
if (gridSummon.quick_summon) return <i className={styles.quickSummon} />
}
}
const characterImage = () => {
const gridCharacter = gridObject as GridCharacter
const character = object as Character
// Change the image based on the uncap level
let suffix = '01'
if (gridCharacter.uncap_level == 6) suffix = '04'
else if (gridCharacter.uncap_level == 5) suffix = '03'
else if (gridCharacter.uncap_level > 2) suffix = '02'
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_${suffix}.jpg`
}
const summonImage = () => {
const summon = object as Summon
const gridSummon = gridObject as GridSummon
const upgradedSummons = [
'2040094000',
'2040100000',
'2040080000',
'2040098000',
'2040090000',
'2040084000',
'2040003000',
'2040056000',
]
let suffix = ''
if (
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
gridSummon.uncap_level == 5
) {
suffix = '_02'
} else if (
gridSummon.object.uncap.transcendence &&
gridSummon.transcendence_step > 0
) {
suffix = '_03'
}
// Generate the correct source for the summon
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg`
}
const weaponImage = () => {
const gridWeapon = gridObject as GridWeapon
const weapon = object as Weapon
if (gridWeapon.object.element == 0 && gridWeapon.element)
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
else
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
}
const image = () => {
switch (type) {
case 'character':
return characterImage()
case 'summon':
return summonImage()
case 'weapon':
return weaponImage()
}
}
const summonProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
</div>
)
const weaponProficiency = (
<div className={styles.icons}>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && !Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency]}
size="small"
/>
)}
</div>
)
const characterProficiency = (
<div
className={classNames({
[styles.icons]: true,
})}
>
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
{'proficiency' in object && Array.isArray(object.proficiency) && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[0]]}
size="small"
/>
)}
{'proficiency' in object &&
Array.isArray(object.proficiency) &&
object.proficiency.length > 1 && (
<WeaponLabelIcon
labelType={Proficiency[object.proficiency[1]]}
size="small"
/>
)}
</div>
)
function proficiency() {
switch (type) {
case 'character':
return characterProficiency
case 'summon':
return summonProficiency
case 'weapon':
return weaponProficiency
}
}
return (
<header className={styles.root}>
<div className={styles.title}>
<h4>{object.name[locale]}</h4>
<div className={styles.image}>
{overlay()}
<img alt={object.name[locale]} src={image()} />
</div>
</div>
<div className={styles.subInfo}>
{proficiency()}
<UncapIndicator
className="hovercard"
type={type}
ulb={object.uncap.ulb || false}
flb={object.uncap.flb || false}
transcendenceStage={
'transcendence_step' in gridObject
? gridObject.transcendence_step
: 0
}
special={'special' in object ? object.special : false}
/>
</div>
</header>
)
}
export default HovercardHeader

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