diff --git a/.env.local b/.env.local new file mode 100644 index 00000000..dbecdbb6 --- /dev/null +++ b/.env.local @@ -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 diff --git a/app/layout.tsx b/app/layout.tsx index 7cf549ff..382f3d0c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,6 @@ -import { Metadata } from 'next' +import { Metadata, Viewport } from 'next' import localFont from 'next/font/local' -import { Viewport } from '@radix-ui/react-toast' +import { Viewport as ToastViewport } from '@radix-ui/react-toast' import '../styles/globals.scss' @@ -13,7 +13,13 @@ import UpdateToastClient from './components/UpdateToastClient' export const metadata: Metadata = { title: 'granblue.team', description: 'Create, save, and share Granblue Fantasy party compositions', - viewport: 'viewport-fit=cover, width=device-width, initial-scale=1.0', +} + +// Viewport configuration (Next.js 13+ requires separate export) +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', } // Font @@ -35,7 +41,7 @@ export default function RootLayout({
{children}
- + diff --git a/app/lib/data.ts b/app/lib/data.ts index 075c9c13..d2e37e2e 100644 --- a/app/lib/data.ts +++ b/app/lib/data.ts @@ -1,8 +1,7 @@ -import { unstable_cache } from 'next/cache'; import { fetchFromApi } from './api-utils'; -// Cached server-side data fetching functions -// These are wrapped with React's cache function to deduplicate requests +// Server-side data fetching functions +// Next.js automatically deduplicates requests within the same render // Get teams with optional filters export async function getTeams({ @@ -18,181 +17,132 @@ export async function getTeams({ page?: number; username?: string; }) { - const key = [ - 'getTeams', - String(element ?? ''), - String(raid ?? ''), - String(recency ?? ''), - String(page ?? 1), - String(username ?? ''), - ]; + const queryParams: Record = {}; + if (element) queryParams.element = element.toString(); + if (raid) queryParams.raid_id = raid; + if (recency) queryParams.recency = recency; + if (page) queryParams.page = page.toString(); - const run = unstable_cache(async () => { - const queryParams: Record = {}; - 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`; + } - let endpoint = '/parties'; - if (username) { - endpoint = `/users/${username}/parties`; - } + const queryString = new URLSearchParams(queryParams).toString(); + if (queryString) endpoint += `?${queryString}`; - 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; - } - }, key, { revalidate: 60 }); - - return run(); + 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) { - const key = ['getTeam', String(shortcode)]; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi(`/parties/${shortcode}`); - return data; - } catch (error) { - console.error(`Failed to fetch team with shortcode ${shortcode}`, error); - throw error; - } - }, key, { revalidate: 60 }); - return run(); + 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) { - const key = ['getUserInfo', String(username)]; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi(`/users/info/${username}`); - return data; - } catch (error) { - console.error(`Failed to fetch user info for ${username}`, error); - throw error; - } - }, key, { revalidate: 60 }); - return run(); + 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() { - const key = ['getRaidGroups']; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi('/raids/groups'); - return data; - } catch (error) { - console.error('Failed to fetch raid groups', error); - throw error; - } - }, key, { revalidate: 300 }); - return run(); + 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() { - const key = ['getVersion']; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi('/version'); - return data; - } catch (error) { - console.error('Failed to fetch version info', error); - throw error; - } - }, key, { revalidate: 300 }); - return run(); + 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() { - const key = ['getFavorites']; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi('/parties/favorites'); - return data; - } catch (error) { - console.error('Failed to fetch favorites', error); - throw error; - } - }, key, { revalidate: 60 }); - return run(); + 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) { - const key = ['getJobs', String(element ?? '')]; - const run = unstable_cache(async () => { - try { - const queryParams: Record = {}; - if (element) queryParams.element = element.toString(); + try { + const queryParams: Record = {}; + if (element) queryParams.element = element.toString(); - let endpoint = '/jobs'; - const queryString = new URLSearchParams(queryParams).toString(); - if (queryString) endpoint += `?${queryString}`; + 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; - } - }, key, { revalidate: 300 }); - return run(); + 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) { - const key = ['getJob', String(jobId)]; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi(`/jobs/${jobId}`); - return data; - } catch (error) { - console.error(`Failed to fetch job with ID ${jobId}`, error); - throw error; - } - }, key, { revalidate: 300 }); - return run(); + 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) { - const key = ['getJobSkills', String(jobId ?? '')]; - const run = unstable_cache(async () => { - 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; - } - }, key, { revalidate: 300 }); - return run(); + 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) { - const key = ['getJobAccessories', String(jobId)]; - const run = unstable_cache(async () => { - try { - const data = await fetchFromApi(`/jobs/${jobId}/accessories`); - return data; - } catch (error) { - console.error(`Failed to fetch accessories for job ${jobId}`, error); - throw error; - } - }, key, { revalidate: 300 }); - return run(); -} + try { + const data = await fetchFromApi(`/jobs/${jobId}/accessories`); + return data; + } catch (error) { + console.error(`Failed to fetch accessories for job ${jobId}`, error); + throw error; + } +} \ No newline at end of file diff --git a/app/new/NewPartyClient.tsx b/app/new/NewPartyClient.tsx index d7ce57c1..044ebb4e 100644 --- a/app/new/NewPartyClient.tsx +++ b/app/new/NewPartyClient.tsx @@ -1,11 +1,10 @@ 'use client' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'next-i18next' import { useRouter } from 'next/navigation' // Components -import NewHead from '~/components/head/NewHead' import Party from '~/components/party/Party' import ErrorSection from '~/components/ErrorSection' @@ -13,6 +12,7 @@ import ErrorSection from '~/components/ErrorSection' 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 @@ -26,6 +26,9 @@ const NewPartyClient: React.FC = ({ const { t } = useTranslation('common') const router = useRouter() + // State for tab management + const [selectedTab, setSelectedTab] = useState(GridType.Weapon) + // Initialize app state for a new party useEffect(() => { // Reset app state for new party @@ -40,41 +43,18 @@ const NewPartyClient: React.FC = ({ } }, [raidGroups]) - // Handle save action - async function handleSave(shouldNavigate = true) { - try { - // Prepare party data - const party = { - name: appState.parties[0]?.name || '', - description: appState.parties[0]?.description || '', - visibility: appState.parties[0]?.visibility || 'public', - element: appState.parties[0]?.element || 1, // Default to Wind - raid_id: appState.parties[0]?.raid?.id - } - - // Save the party - const response = await fetch('/api/parties', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ party }) - }) - - const data = await response.json() - - if (data && data.shortcode && shouldNavigate) { - // Navigate to the new party page - router.push(`/p/${data.shortcode}`) - } - - return data - } catch (error) { - console.error('Error saving party', error) - return null - } + // 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 ( = ({ } return ( - <> - - - + ) } diff --git a/app/p/[party]/PartyPageClient.tsx b/app/p/[party]/PartyPageClient.tsx index 0c877537..c8e8d3d5 100644 --- a/app/p/[party]/PartyPageClient.tsx +++ b/app/p/[party]/PartyPageClient.tsx @@ -1,11 +1,12 @@ 'use client' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'next-i18next' import { useRouter } from 'next/navigation' // Utils import { appState } from '~/utils/appState' +import { GridType } from '~/utils/enums' // Components import Party from '~/components/party/Party' @@ -21,6 +22,9 @@ const PartyPageClient: React.FC = ({ party, raidGroups }) => { const router = useRouter() const { t } = useTranslation('common') + // State for tab management + const [selectedTab, setSelectedTab] = useState(GridType.Weapon) + // Initialize app state useEffect(() => { if (party) { @@ -29,45 +33,18 @@ const PartyPageClient: React.FC = ({ party, raidGroups }) => { } }, [party, raidGroups]) - // Handle remix action - async function handleRemix() { - if (!party || !party.shortcode) return - - try { - const response = await fetch(`/api/parties/${party.shortcode}/remix`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }) - - const data = await response.json() - - if (data && data.shortcode) { - // Navigate to the new remixed party - router.push(`/p/${data.shortcode}`) - } - } catch (error) { - console.error('Error remixing party', error) - } + // Handle tab change + const handleTabChanged = (value: string) => { + const tabType = parseInt(value) as GridType + setSelectedTab(tabType) } - // Handle deletion action - async function handleDelete() { - if (!party || !party.shortcode) return - - try { - await fetch(`/api/parties/${party.shortcode}`, { - method: 'DELETE' - }) - - // Navigate to teams page after deletion - router.push('/teams') - } catch (error) { - console.error('Error deleting party', error) - } + // Navigation helper (not used for existing parties but required by interface) + const pushHistory = (path: string) => { + router.push(path) } + // Error case if (!party) { return ( @@ -83,9 +60,11 @@ const PartyPageClient: React.FC = ({ party, raidGroups }) => { return ( <> diff --git a/components/party/EditPartyModal/index.tsx b/components/party/EditPartyModal/index.tsx index 1fed3e8a..526d0498 100644 --- a/components/party/EditPartyModal/index.tsx +++ b/components/party/EditPartyModal/index.tsx @@ -21,7 +21,7 @@ import SwitchTableField from '~components/common/SwitchTableField' import TableField from '~components/common/TableField' import capitalizeFirstLetter from '~utils/capitalizeFirstLetter' -import type { DetailsObject } from 'types' +import type { DetailsObject } from '~types' import type { DialogProps } from '@radix-ui/react-dialog' import type { JSONContent } from '@tiptap/core' diff --git a/components/party/PartyFooter/index.tsx b/components/party/PartyFooter/index.tsx index 6549a69f..25e2ee32 100644 --- a/components/party/PartyFooter/index.tsx +++ b/components/party/PartyFooter/index.tsx @@ -20,7 +20,7 @@ import api from '~utils/api' import { appState } from '~utils/appState' import { youtube } from '~utils/youtube' -import type { DetailsObject } from 'types' +import type { DetailsObject } from '~types' import RemixIcon from '~public/icons/Remix.svg' import EditIcon from '~public/icons/Edit.svg' diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx index 11757858..f2794d5f 100644 --- a/components/party/PartyHeader/index.tsx +++ b/components/party/PartyHeader/index.tsx @@ -31,7 +31,7 @@ import SaveIcon from '~public/icons/Save.svg' import PrivateIcon from '~public/icons/Private.svg' import UnlistedIcon from '~public/icons/Unlisted.svg' -import type { DetailsObject } from 'types' +import type { DetailsObject } from '~types' import styles from './index.module.scss' diff --git a/components/party/PartyVisibilityDialog/index.tsx b/components/party/PartyVisibilityDialog/index.tsx index cfe3f34f..ab736c8b 100644 --- a/components/party/PartyVisibilityDialog/index.tsx +++ b/components/party/PartyVisibilityDialog/index.tsx @@ -11,7 +11,7 @@ import DialogHeader from '~components/common/DialogHeader' import DialogFooter from '~components/common/DialogFooter' import DialogContent from '~components/common/DialogContent' -import type { DetailsObject } from 'types' +import type { DetailsObject } from '~types' import type { DialogProps } from '@radix-ui/react-dialog' import { appState } from '~utils/appState' diff --git a/i18n/request.ts b/i18n/request.ts new file mode 100644 index 00000000..63bbcca3 --- /dev/null +++ b/i18n/request.ts @@ -0,0 +1,17 @@ +import {getRequestConfig} from 'next-intl/server' +import {locales, defaultLocale, type Locale} from '../i18n.config' + +// next-intl v4: global request config used by getMessages() +export default getRequestConfig(async ({requestLocale}) => { + let locale = (await requestLocale) as Locale | null; + if (!locale || !locales.includes(locale)) { + locale = defaultLocale; + } + + // Load only i18n namespaces; exclude content data with dotted keys + const common = (await import(`../public/locales/${locale}/common.json`)).default; + const about = (await import(`../public/locales/${locale}/about.json`)).default; + const messages = {common, about} as const; + + return {locale, messages}; +}); diff --git a/next-env.d.ts b/next-env.d.ts index fd36f949..725dd6f2 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/prd/fix-party-component-interface.md b/prd/fix-party-component-interface.md new file mode 100644 index 00000000..9af0509b --- /dev/null +++ b/prd/fix-party-component-interface.md @@ -0,0 +1,243 @@ +# Product Requirements Document: Fix Party Component Interface Mismatch + +## Problem Statement +During the App Router migration, the client components (`NewPartyClient` and `PartyPageClient`) were created with an incorrect interface for the `Party` component. This causes an "Element type is invalid" error preventing pages from loading. + +## Root Cause Analysis + +### What Went Wrong +1. **Interface Mismatch**: The Party component expects props like `selectedTab`, `raidGroups`, and `handleTabChanged`, but client components are passing `party`, `isNew`, and `onSave` +2. **Incomplete Migration**: During the Pages-to-App Router migration, new client wrapper components were created without matching the existing Party component's interface +3. **Missing Context**: The migration didn't account for the Party component being a complete, self-contained system that handles its own state management + +### Current Party Component Architecture +The Party component is a comprehensive system that: +- Manages its own save/create/update logic via internal functions (`createParty()` and `updateParty()`) +- Uses global `appState` from Valtio for state management +- Handles tab switching between Character/Weapon/Summon grids +- Manages edit permissions and authentication +- Contains PartyHeader, PartySegmentedControl, Grid components, and PartyFooter + +### Expected Interface +```typescript +interface Props { + new?: boolean + team?: Party + selectedTab: GridType + raidGroups: RaidGroup[] + handleTabChanged: (value: string) => void + pushHistory?: (path: string) => void +} +``` + +### Current (Incorrect) Usage +```typescript +// In NewPartyClient.tsx + + +// In PartyPageClient.tsx + +``` + +## Solution Options + +### Option 1: Fix Client Components (Recommended) +Update the client wrapper components to match the Party component's expected interface. + +**Pros:** +- Maintains existing Party component architecture +- Minimal changes required +- Preserves all existing functionality +- Lower risk of breaking existing features + +**Cons:** +- Client components need to manage additional state + +### Option 2: Create Adapter Components +Create intermediate adapter components that translate between the two interfaces. + +**Pros:** +- Keeps existing client component logic +- Provides flexibility for future changes + +**Cons:** +- Adds complexity +- Extra layer of abstraction +- Performance overhead + +### Option 3: Refactor Party Component +Change the Party component to accept the simpler interface the client components expect. + +**Pros:** +- Simpler client components +- More explicit prop passing + +**Cons:** +- Major refactoring required +- Risk of breaking existing functionality +- Would need to extract and relocate save/update logic +- Affects multiple components + +## Implementation Plan (Option 1 - Recommended) + +### Phase 1: Fix Critical Props Interface + +#### Task List +- [x] Remove unstable_cache from data.ts (completed) +- [x] Fix viewport metadata in layout.tsx (completed) +- [x] Fix Radix Toast import (completed) +- [ ] Fix NewPartyClient props interface +- [ ] Fix PartyPageClient props interface +- [ ] Add selectedTab state management to both components +- [ ] Implement handleTabChanged functions +- [ ] Add pushHistory navigation wrapper +- [ ] Ensure raidGroups are passed correctly +- [ ] Remove next/head usage from Head components + +#### Files to Modify + +##### Critical Files (Must Change) +```typescript +/app/new/NewPartyClient.tsx + - Add: useState for selectedTab (default: GridType.Weapon) + - Add: handleTabChanged function + - Change: party → team prop + - Change: isNew → new prop + - Remove: onSave prop (Party handles internally) + - Add: raidGroups prop from parent + - Add: pushHistory function using router.push + +/app/p/[party]/PartyPageClient.tsx + - Add: useState for selectedTab (default: GridType.Weapon) + - Add: handleTabChanged function + - Change: party → team prop + - Remove: onRemix and onDelete props (Party handles internally) + - Add: raidGroups prop from parent + - Add: pushHistory function using router.push + +/app/new/page.tsx + - Verify: raidGroups are fetched correctly + - Ensure: raidGroups are passed to NewPartyClient + +/app/p/[party]/page.tsx + - Add: Fetch raidGroups data + - Pass: raidGroups to PartyPageClient +``` + +##### Head Component Cleanup +```typescript +/components/head/NewHead/index.tsx + - Remove entirely (metadata handled in page.tsx) + +/components/head/ProfileHead/index.tsx + - Remove entirely (metadata handled in page.tsx) + +/components/head/SavedHead/index.tsx + - Remove entirely (metadata handled in page.tsx) + +/components/head/TeamsHead/index.tsx + - Remove entirely (metadata handled in page.tsx) + +/components/party/PartyHead/index.tsx + - Refactor to not use next/head + - Or remove if not needed in App Router +``` + +### Phase 2: Restore Toast Viewport +```typescript +/app/layout.tsx + - Uncomment and fix ToastViewport component + - Ensure proper import from @radix-ui/react-toast +``` + +## Testing Requirements + +### Functional Tests +1. **Page Loading** + - [ ] /new page loads without errors + - [ ] /p/[party] pages load without errors + - [ ] No "Element type is invalid" errors in console + +2. **Party Creation** + - [ ] Can create a new party + - [ ] Party name can be edited + - [ ] Party description can be added + - [ ] Save functionality works + +3. **Party Editing** + - [ ] Can edit existing party + - [ ] Changes persist after save + - [ ] Edit permissions work correctly + +4. **Tab Navigation** + - [ ] Can switch between Characters tab + - [ ] Can switch to Weapons tab + - [ ] Can switch to Summons tab + - [ ] Tab state persists during editing + +5. **State Management** + - [ ] appState updates correctly + - [ ] Valtio state management works + - [ ] No state inconsistencies + +### Browser Testing +- [ ] Chrome/Edge +- [ ] Firefox +- [ ] Safari + +## Success Criteria +- No runtime errors on party pages +- All party creation/editing functionality works +- Tab navigation functions correctly +- Pages load successfully in development and production +- Metadata is properly rendered for SEO +- No console warnings about invalid props + +## Risk Mitigation +1. **Testing Strategy** + - Test each change incrementally + - Verify functionality after each component update + - Use development server for immediate feedback + +2. **Rollback Plan** + - Create focused commits for each component + - Keep changes minimal and isolated + - Document any discovered issues + +3. **Communication** + - Document any unexpected behavior + - Note any additional required changes + - Update PRD if scope changes + +## Timeline +- **Phase 1**: Immediate (blocking issue) + - Estimated: 1-2 hours + - Priority: Critical + +- **Phase 2**: Follow-up + - Estimated: 30 minutes + - Priority: Medium + +## Dependencies +- GridType enum from utils/enums.tsx +- RaidGroup type definitions +- Valtio state management +- Next.js App Router navigation + +## Open Questions +1. Should PartyHead component be refactored or removed? +2. Are there other pages using the Party component that need updates? +3. Should we add TypeScript interfaces for better type safety? + +## Notes +- The Party component is well-designed as a self-contained system +- The issue is purely an interface mismatch, not a design flaw +- This pattern (self-contained components) might be worth documenting for future migrations \ No newline at end of file