From 426645813e4943182fe4066c2eb8dc4214a131fb Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 12:16:42 -0700 Subject: [PATCH] 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:). ### 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. --- CLAUDE.md | 28 ++ app/[username]/ProfilePageClient.tsx | 240 ++++++++++++ app/[username]/page.tsx | 87 +++++ app/api/auth/login/route.ts | 100 +++++ app/api/auth/logout/route.ts | 20 + app/api/auth/signup/route.ts | 70 ++++ app/api/favorites/route.ts | 82 +++++ app/api/jobs/[id]/accessories/route.ts | 22 ++ app/api/jobs/[id]/skills/route.ts | 22 ++ app/api/jobs/route.ts | 32 ++ app/api/jobs/skills/route.ts | 17 + app/api/parties/[shortcode]/remix/route.ts | 32 ++ app/api/parties/[shortcode]/route.ts | 89 +++++ app/api/parties/route.ts | 80 ++++ app/api/raids/groups/route.ts | 18 + app/api/search/[object]/route.ts | 50 +++ app/api/search/route.ts | 39 ++ app/api/users/info/[username]/route.ts | 23 ++ app/api/users/settings/route.ts | 89 +++++ app/api/version/route.ts | 18 + app/components/Header.tsx | 405 +++++++++++++++++++++ app/components/UpdateToastClient.tsx | 63 ++++ app/error.tsx | 37 ++ app/global-error.tsx | 47 +++ app/layout.tsx | 48 +++ app/lib/api-utils.ts | 173 +++++++++ app/lib/data.ts | 198 ++++++++++ app/new/NewPartyClient.tsx | 101 +++++ app/new/page.tsx | 36 ++ app/not-found.tsx | 27 ++ app/p/[party]/PartyPageClient.tsx | 95 +++++ app/p/[party]/page.tsx | 82 +++++ app/page.tsx | 6 + app/saved/SavedPageClient.tsx | 207 +++++++++++ app/saved/page.tsx | 93 +++++ app/server-error/page.tsx | 32 ++ app/styles/error.scss | 77 ++++ app/teams/TeamsPageClient.tsx | 248 +++++++++++++ app/teams/page.tsx | 65 ++++ app/unauthorized/page.tsx | 23 ++ middleware.ts | 102 ++++++ mise.toml | 56 +++ next-env.d.ts | 1 + package.json | 139 +++---- pages/api/preview/[shortcode].ts | 33 -- styles/globals.scss | 1 + supervisord/hensei-web.ini | 17 + tsconfig.json | 28 +- 48 files changed, 3492 insertions(+), 106 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/[username]/ProfilePageClient.tsx create mode 100644 app/[username]/page.tsx create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/signup/route.ts create mode 100644 app/api/favorites/route.ts create mode 100644 app/api/jobs/[id]/accessories/route.ts create mode 100644 app/api/jobs/[id]/skills/route.ts create mode 100644 app/api/jobs/route.ts create mode 100644 app/api/jobs/skills/route.ts create mode 100644 app/api/parties/[shortcode]/remix/route.ts create mode 100644 app/api/parties/[shortcode]/route.ts create mode 100644 app/api/parties/route.ts create mode 100644 app/api/raids/groups/route.ts create mode 100644 app/api/search/[object]/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/api/users/info/[username]/route.ts create mode 100644 app/api/users/settings/route.ts create mode 100644 app/api/version/route.ts create mode 100644 app/components/Header.tsx create mode 100644 app/components/UpdateToastClient.tsx create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/layout.tsx create mode 100644 app/lib/api-utils.ts create mode 100644 app/lib/data.ts create mode 100644 app/new/NewPartyClient.tsx create mode 100644 app/new/page.tsx create mode 100644 app/not-found.tsx create mode 100644 app/p/[party]/PartyPageClient.tsx create mode 100644 app/p/[party]/page.tsx create mode 100644 app/page.tsx create mode 100644 app/saved/SavedPageClient.tsx create mode 100644 app/saved/page.tsx create mode 100644 app/server-error/page.tsx create mode 100644 app/styles/error.scss create mode 100644 app/teams/TeamsPageClient.tsx create mode 100644 app/teams/page.tsx create mode 100644 app/unauthorized/page.tsx create mode 100644 middleware.ts create mode 100644 mise.toml delete mode 100644 pages/api/preview/[shortcode].ts create mode 100644 supervisord/hensei-web.ini diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9df070cc --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/app/[username]/ProfilePageClient.tsx b/app/[username]/ProfilePageClient.tsx new file mode 100644 index 00000000..161a6da5 --- /dev/null +++ b/app/[username]/ProfilePageClient.tsx @@ -0,0 +1,240 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import InfiniteScroll from 'react-infinite-scroll-component' + +// Components +import FilterBar from '~/components/filters/FilterBar' +import ProfileHead from '~/components/head/ProfileHead' +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 Party { + id: string; + shortcode: string; + name: string; + element: number; + // Add other properties as needed +} + +interface User { + id: string; + username: string; + avatar: { + picture: string; + element: string; + }; + gender: string; + // Add other properties as needed +} + +interface Props { + initialData: { + user: User; + teams: Party[]; + raidGroups: any[]; + pagination: Pagination; + }; + initialElement?: number; + initialRaid?: string; + initialRecency?: string; +} + +const ProfilePageClient: React.FC = ({ + initialData, + initialElement, + initialRaid, + initialRecency +}) => { + const { t } = useTranslation('common') + const router = useRouter() + const searchParams = useSearchParams() + + // State management + const [parties, setParties] = useState(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 || '') + + // 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) + } 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 + const url = new URL(`/api/parties`, window.location.origin) + url.searchParams.set('username', initialData.user.username) + 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) + + 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 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 || '') + } + 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) => ( + goToParty(party.shortcode)} + /> + )) + } + + function renderLoading(number: number) { + return ( + + {Array.from({ length: number }, (_, i) => ( + + ))} + + ) + } + + const renderInfiniteScroll = ( + <> + {parties.length === 0 && !loaded && renderLoading(3)} + {parties.length === 0 && loaded && ( +
+

{t('teams.not_found')}

+
+ )} + {parties.length > 0 && ( + currentPage} + loader={renderLoading(3)} + > + {renderParties()} + + )} + + ) + + return ( + <> + + + + + + +
{renderInfiniteScroll}
+ + ) +} + +export default ProfilePageClient \ No newline at end of file diff --git a/app/[username]/page.tsx b/app/[username]/page.tsx new file mode 100644 index 00000000..815f0f2e --- /dev/null +++ b/app/[username]/page.tsx @@ -0,0 +1,87 @@ +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 { + 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() + } + + // Prepare data for client component + const initialData = { + user: userData.user, + teams: teamsData.parties || [], + raidGroups: raidGroupsData.raid_groups || [], + pagination: { + current_page: teamsData.pagination?.current_page || 1, + total_pages: teamsData.pagination?.total_pages || 1, + record_count: teamsData.pagination?.record_count || 0 + } + } + + return ( +
+ +
+ ) + } catch (error) { + console.error(`Error fetching profile data for ${params.username}:`, error) + notFound() + } +} \ No newline at end of file diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 00000000..8ad66868 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,100 @@ +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.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 } + ) + } +} \ No newline at end of file diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..f2726ece --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 00000000..bc1be741 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,70 @@ +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.response?.data?.error) { + const apiError = error.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 } + ) + } +} \ No newline at end of file diff --git a/app/api/favorites/route.ts b/app/api/favorites/route.ts new file mode 100644 index 00000000..3ec336d7 --- /dev/null +++ b/app/api/favorites/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/jobs/[id]/accessories/route.ts b/app/api/jobs/[id]/accessories/route.ts new file mode 100644 index 00000000..d5b95451 --- /dev/null +++ b/app/api/jobs/[id]/accessories/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/jobs/[id]/skills/route.ts b/app/api/jobs/[id]/skills/route.ts new file mode 100644 index 00000000..d4b54522 --- /dev/null +++ b/app/api/jobs/[id]/skills/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts new file mode 100644 index 00000000..e759e675 --- /dev/null +++ b/app/api/jobs/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' +import { fetchFromApi } from '~/app/lib/api-utils' + +// 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 = {} + 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 } + ) + } +} \ No newline at end of file diff --git a/app/api/jobs/skills/route.ts b/app/api/jobs/skills/route.ts new file mode 100644 index 00000000..8495003f --- /dev/null +++ b/app/api/jobs/skills/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { fetchFromApi } from '~/app/lib/api-utils' + +// 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 } + ) + } +} \ No newline at end of file diff --git a/app/api/parties/[shortcode]/remix/route.ts b/app/api/parties/[shortcode]/remix/route.ts new file mode 100644 index 00000000..0173bbee --- /dev/null +++ b/app/api/parties/[shortcode]/remix/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postToApi, revalidate } from '~/app/lib/api-utils'; + +// 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 } + ); + } +} \ No newline at end of file diff --git a/app/api/parties/[shortcode]/route.ts b/app/api/parties/[shortcode]/route.ts new file mode 100644 index 00000000..757fc063 --- /dev/null +++ b/app/api/parties/[shortcode]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { fetchFromApi, putToApi, deleteFromApi, revalidate, PartySchema } from '~/app/lib/api-utils'; + +// 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 } + ); + } +} \ No newline at end of file diff --git a/app/api/parties/route.ts b/app/api/parties/route.ts new file mode 100644 index 00000000..4b130a6d --- /dev/null +++ b/app/api/parties/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { fetchFromApi, postToApi, PartySchema } from '~/app/lib/api-utils'; + +// 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 = {}; + 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 } + ); + } +} \ No newline at end of file diff --git a/app/api/raids/groups/route.ts b/app/api/raids/groups/route.ts new file mode 100644 index 00000000..f7697881 --- /dev/null +++ b/app/api/raids/groups/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchFromApi } from '~/app/lib/api-utils'; + +// 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 } + ); + } +} \ No newline at end of file diff --git a/app/api/search/[object]/route.ts b/app/api/search/[object]/route.ts new file mode 100644 index 00000000..b33a17e1 --- /dev/null +++ b/app/api/search/[object]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 00000000..decbd5b4 --- /dev/null +++ b/app/api/search/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/users/info/[username]/route.ts b/app/api/users/info/[username]/route.ts new file mode 100644 index 00000000..523eefb0 --- /dev/null +++ b/app/api/users/info/[username]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/users/settings/route.ts b/app/api/users/settings/route.ts new file mode 100644 index 00000000..a8ed2195 --- /dev/null +++ b/app/api/users/settings/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/version/route.ts b/app/api/version/route.ts new file mode 100644 index 00000000..d16866bd --- /dev/null +++ b/app/api/version/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchFromApi } from '~/app/lib/api-utils'; + +// 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 } + ); + } +} \ No newline at end of file diff --git a/app/components/Header.tsx b/app/components/Header.tsx new file mode 100644 index 00000000..2b6341d7 --- /dev/null +++ b/app/components/Header.tsx @@ -0,0 +1,405 @@ +'use client' + +import React, { useState } from 'react' +import { deleteCookie } from 'cookies-next' +import { useRouter } from 'next/navigation' +import { useTranslation } from 'next-i18next' +import classNames from 'classnames' +import clonedeep from 'lodash.clonedeep' +import Link from 'next/link' + +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 } = useTranslation('common') + + // Router + const router = useRouter() + const locale = 'en' // TODO: Update when implementing internationalization with App Router + + // 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 ( + {user.username} + ) + } else { + return ( + {t('no_user')} + ) + } + } + + // Rendering: Buttons + const newButton = ( + + + + Browse teams + + + + + ) +} \ No newline at end of file diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 00000000..52b15ad5 --- /dev/null +++ b/app/global-error.tsx @@ -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 ( + + +
+
+

Something went wrong

+

The application has encountered a critical error and cannot continue.

+
+

{error.message || 'An unexpected error occurred'}

+ {error.digest &&

Error ID: {error.digest}

} +
+
+ + + Report on Discord + +
+
+
+ + + ) +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..ab1e7169 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,48 @@ +import { Metadata } from 'next' +import localFont from 'next/font/local' +import { ToastProvider, Viewport } from '@radix-ui/react-toast' +import { TooltipProvider } from '@radix-ui/react-tooltip' +import { ThemeProvider } from 'next-themes' + +import '../styles/globals.scss' + +// Components +import Header from './components/Header' +import UpdateToastClient from './components/UpdateToastClient' + +// Metadata +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', +} + +// Font +const goalking = localFont({ + src: '../pages/fonts/gk-variable.woff2', + fallback: ['system-ui', 'inter', 'helvetica neue', 'sans-serif'], + variable: '--font-goalking', +}) + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + +
+ +
{children}
+ + + + + + + ) +} \ No newline at end of file diff --git a/app/lib/api-utils.ts b/app/lib/api-utils.ts new file mode 100644 index 00000000..841071ef --- /dev/null +++ b/app/lib/api-utils.ts @@ -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), +}); diff --git a/app/lib/data.ts b/app/lib/data.ts new file mode 100644 index 00000000..075c9c13 --- /dev/null +++ b/app/lib/data.ts @@ -0,0 +1,198 @@ +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 + +// 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 key = [ + 'getTeams', + String(element ?? ''), + String(raid ?? ''), + String(recency ?? ''), + String(page ?? 1), + String(username ?? ''), + ]; + + 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`; + } + + 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(); +} + +// 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(); +} + +// 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(); +} + +// 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(); +} + +// 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(); +} + +// 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(); +} + +// 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(); + + 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(); +} + +// 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(); +} + +// 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(); +} + +// 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(); +} diff --git a/app/new/NewPartyClient.tsx b/app/new/NewPartyClient.tsx new file mode 100644 index 00000000..d7ce57c1 --- /dev/null +++ b/app/new/NewPartyClient.tsx @@ -0,0 +1,101 @@ +'use client' + +import React, { useEffect } 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' + +// Utils +import { appState, initialAppState } from '~/utils/appState' +import { accountState } from '~/utils/accountState' +import clonedeep from 'lodash.clonedeep' + +interface Props { + raidGroups: any[]; // Replace with proper RaidGroup type + error?: boolean; +} + +const NewPartyClient: React.FC = ({ + raidGroups, + error = false +}) => { + const { t } = useTranslation('common') + const router = useRouter() + + // 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 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 + } + } + + if (error) { + return ( + + ) + } + + return ( + <> + + + + ) +} + +export default NewPartyClient \ No newline at end of file diff --git a/app/new/page.tsx b/app/new/page.tsx new file mode 100644 index 00000000..ba2554fd --- /dev/null +++ b/app/new/page.tsx @@ -0,0 +1,36 @@ +import { Metadata } from 'next' +import { getRaidGroups } from '~/app/lib/data' +import NewPartyClient from './NewPartyClient' + +// 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 ( +
+ +
+ ) + } catch (error) { + console.error("Error fetching data for new party page:", error) + + // Provide empty data for error case + return ( +
+ +
+ ) + } +} \ No newline at end of file diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..f71f5537 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,27 @@ +import { Metadata } from 'next' +import Link from 'next/link' +import { useTranslation } from 'next-i18next' + +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 ( +
+
+

Not Found

+

The page you're looking for couldn't be found

+
+ + Create a new party + + + Browse teams + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/p/[party]/PartyPageClient.tsx b/app/p/[party]/PartyPageClient.tsx new file mode 100644 index 00000000..0c877537 --- /dev/null +++ b/app/p/[party]/PartyPageClient.tsx @@ -0,0 +1,95 @@ +'use client' + +import React, { useEffect } from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter } from 'next/navigation' + +// Utils +import { appState } from '~/utils/appState' + +// 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 = ({ party, raidGroups }) => { + const router = useRouter() + const { t } = useTranslation('common') + + // Initialize app state + useEffect(() => { + if (party) { + appState.parties[0] = party + appState.raidGroups = 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 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) + } + } + + // Error case + if (!party) { + return ( + + ) + } + + return ( + <> + + + + ) +} + +export default PartyPageClient \ No newline at end of file diff --git a/app/p/[party]/page.tsx b/app/p/[party]/page.tsx new file mode 100644 index 00000000..a4e1eec9 --- /dev/null +++ b/app/p/[party]/page.tsx @@ -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 { + 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 ( +
+ +
+ ) + } catch (error) { + console.error(`Error fetching party data for ${params.party}:`, error) + notFound() + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 00000000..f7ab15af --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation' + +export default function HomePage() { + // In the App Router, we can use redirect directly in a Server Component + redirect('/new') +} \ No newline at end of file diff --git a/app/saved/SavedPageClient.tsx b/app/saved/SavedPageClient.tsx new file mode 100644 index 00000000..7b8a9ed8 --- /dev/null +++ b/app/saved/SavedPageClient.tsx @@ -0,0 +1,207 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter, useSearchParams } from 'next/navigation' + +// Components +import FilterBar from '~/components/filters/FilterBar' +import SavedHead from '~/components/head/SavedHead' +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 Party { + id: string; + shortcode: string; + name: string; + element: number; + // Add other properties as needed +} + +interface Props { + initialData: { + teams: Party[]; + raidGroups: any[]; + totalCount: number; + }; + initialElement?: number; + initialRaid?: string; + initialRecency?: string; + error?: boolean; +} + +const SavedPageClient: React.FC = ({ + initialData, + initialElement, + initialRaid, + initialRecency, + error = false +}) => { + const { t } = useTranslation('common') + const router = useRouter() + const searchParams = useSearchParams() + + // State management + const [parties, setParties] = useState(initialData.teams) + const [element, setElement] = useState(initialElement || 0) + const [raid, setRaid] = useState(initialRaid || '') + const [recency, setRecency] = useState(initialRecency || '') + 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) + } 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 || '') + } + 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) => ( + goToParty(party.shortcode)} + onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)} + /> + )) + } + + function renderLoading(number: number) { + return ( + + {Array.from({ length: number }, (_, i) => ( + + ))} + + ) + } + + if (error) { + return ( + + ) + } + + return ( + <> + + + +

{t('saved.title')}

+
+ +
+ {parties.length === 0 ? ( +
+

{t('saved.not_found')}

+
+ ) : ( + {renderParties()} + )} +
+ + ) +} + +export default SavedPageClient \ No newline at end of file diff --git a/app/saved/page.tsx b/app/saved/page.tsx new file mode 100644 index 00000000..7656dec5 --- /dev/null +++ b/app/saved/page.tsx @@ -0,0 +1,93 @@ +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' + +// 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.parties || []; + + if (element) { + filteredTeams = filteredTeams.filter(party => party.element === element) + } + + if (raid) { + filteredTeams = filteredTeams.filter(party => party.raid?.id === raid) + } + + // Prepare data for client component + const initialData = { + teams: filteredTeams, + raidGroups: raidGroupsData.raid_groups || [], + totalCount: savedTeamsData.parties?.length || 0 + } + + return ( +
+ +
+ ) + } catch (error) { + console.error("Error fetching saved teams:", error) + + // Provide empty data for error case + return ( +
+ +
+ ) + } +} \ No newline at end of file diff --git a/app/server-error/page.tsx b/app/server-error/page.tsx new file mode 100644 index 00000000..6ef2465f --- /dev/null +++ b/app/server-error/page.tsx @@ -0,0 +1,32 @@ +import { Metadata } from 'next' +import Link from 'next/link' + +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 ( +
+
+

Internal Server Error

+

The server encountered an internal error and was unable to complete your request.

+

Our team has been notified and is working to fix the issue.

+
+ + Browse teams + + + Report on Discord + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/styles/error.scss b/app/styles/error.scss new file mode 100644 index 00000000..e20ab2ec --- /dev/null +++ b/app/styles/error.scss @@ -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); + } + } + } +} \ No newline at end of file diff --git a/app/teams/TeamsPageClient.tsx b/app/teams/TeamsPageClient.tsx new file mode 100644 index 00000000..b6be996d --- /dev/null +++ b/app/teams/TeamsPageClient.tsx @@ -0,0 +1,248 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter, 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 Party { + id: string; + shortcode: string; + name: string; + element: number; + // Add other properties as needed +} + +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 = ({ + initialData, + initialElement, + initialRaid, + initialRecency, + error = false +}) => { + const { t } = useTranslation('common') + const router = useRouter() + const searchParams = useSearchParams() + + // State management + const [parties, setParties] = useState(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 || '') + 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) + } 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) + + 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 || '') + } + 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) => ( + goTo(party.shortcode)} + onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)} + /> + )) + } + + function renderLoading(number: number) { + return ( + + {Array.from({ length: number }, (_, i) => ( + + ))} + + ) + } + + if (error) { + return ( + + ) + } + + const renderInfiniteScroll = ( + <> + {parties.length === 0 && !loaded && renderLoading(3)} + {parties.length === 0 && loaded && ( +
+

{t('teams.not_found')}

+
+ )} + {parties.length > 0 && ( + currentPage} + loader={renderLoading(3)} + > + {renderParties()} + + )} + + ) + + return ( + <> + +

{t('teams.title')}

+
+ +
{renderInfiniteScroll}
+ + ) +} + +export default TeamsPageClient \ No newline at end of file diff --git a/app/teams/page.tsx b/app/teams/page.tsx new file mode 100644 index 00000000..43cc4232 --- /dev/null +++ b/app/teams/page.tsx @@ -0,0 +1,65 @@ +import { Metadata } from 'next' +import React from 'react' +import { getTeams as fetchTeams, getRaidGroups } from '~/app/lib/data' +import TeamsPageClient from './TeamsPageClient' + +// 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.parties || [], + raidGroups: raidGroupsData.raid_groups || [], + pagination: { + current_page: teamsData.pagination?.current_page || 1, + total_pages: teamsData.pagination?.total_pages || 1, + record_count: teamsData.pagination?.record_count || 0 + } + }; + + return ( +
+ {/* Pass server data to client component */} + +
+ ); + } catch (error) { + console.error("Error fetching teams data:", error); + + // Fallback data for error case + return ( +
+ +
+ ); + } +} \ No newline at end of file diff --git a/app/unauthorized/page.tsx b/app/unauthorized/page.tsx new file mode 100644 index 00000000..9413f383 --- /dev/null +++ b/app/unauthorized/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next' +import Link from 'next/link' + +export const metadata: Metadata = { + title: 'Unauthorized / granblue.team', + description: 'You don\'t have permission to perform that action' +} + +export default function UnauthorizedPage() { + return ( +
+
+

Unauthorized

+

You don't have permission to perform that action

+
+ + Browse teams + +
+
+
+ ) +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..1c2efcf3 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +// Define paths that require authentication +const PROTECTED_PATHS = [ + // API paths that require auth + '/api/parties/create', + '/api/parties/update', + '/api/parties/delete', + '/api/favorites', + '/api/users/settings', + + // Page paths that require auth + '/saved', + '/profile', +] + +// Paths that are public but have protected actions +const MIXED_AUTH_PATHS = [ + '/api/parties', // GET is public, POST requires auth + '/p/', // Viewing is public, editing requires auth +] + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Check if path requires authentication + const isProtectedPath = PROTECTED_PATHS.some(path => + pathname === path || pathname.startsWith(path + '/') + ) + + // For mixed auth paths, check the request method + const isMixedAuthPath = MIXED_AUTH_PATHS.some(path => + pathname === path || pathname.startsWith(path) + ) + + const needsAuth = isProtectedPath || + (isMixedAuthPath && ['POST', 'PUT', 'DELETE'].includes(request.method)) + + if (needsAuth) { + // Get the authentication cookie + const accountCookie = request.cookies.get('account') + + // If no token or invalid format, redirect to login + if (!accountCookie?.value) { + // For API routes, return 401 Unauthorized + if (pathname.startsWith('/api/')) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + // For page routes, redirect to teams page + return NextResponse.redirect(new URL('/teams', request.url)) + } + + try { + // Parse the cookie to check for token + const accountData = JSON.parse(accountCookie.value) + + if (!accountData.token) { + // For API routes, return 401 Unauthorized + if (pathname.startsWith('/api/')) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + // For page routes, redirect to teams page + return NextResponse.redirect(new URL('/teams', request.url)) + } + } catch (e) { + // For API routes, return 401 Unauthorized if cookie is invalid + if (pathname.startsWith('/api/')) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + // For page routes, redirect to teams page + return NextResponse.redirect(new URL('/teams', request.url)) + } + } + + return NextResponse.next() +} + +// Configure the middleware to run on specific paths +export const config = { + matcher: [ + // Match all API routes + '/api/:path*', + // Match specific protected pages + '/saved', + '/profile', + // Match party pages for mixed auth + '/p/:path*', + ], +} \ No newline at end of file diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..24bb4645 --- /dev/null +++ b/mise.toml @@ -0,0 +1,56 @@ +min_version = "2024.9.5" + +[env] +PROJECT_NAME = "{{ config_root | basename }}" +NODE_ENV = "development" + +# Enable relative paths for imports. +NODE_PATH='src/' + +# App URLs +# Don't add a trailing slash to these URLs. +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_PUBLIC_SIERO_IMG_URL='/images/' + +# You will have to use a Google account to acquire a Youtube API key +# or embeds will not work! +NEXT_PUBLIC_YOUTUBE_API_KEY='AIzaSyB8D2IM5C4JDhEC5IcY-Sdzr-TAvw-ZlX4' + +[tools] +node = "lts" +npm = "{{ get_env(name='NPM_VERSION', default='8') }}" + +[tasks.install] +description = "Install Node.js dependencies" +run = "npm install" + +[tasks.dev] +description = "Run the Next.js development server" +alias = "d" +run = "npm run dev" + +[tasks.build] +description = "Build the Next.js application" +alias = "b" +run = "npm run build" + +[tasks.start] +description = "Start the Next.js production server" +alias = "s" +run = "npm run start" + +[tasks.lint] +description = "Run linting" +alias = "l" +run = "npm run lint" + +[tasks.storybook] +description = "Start Storybook" +alias = "sb" +run = "npm run storybook" + +[tasks.build-storybook] +description = "Build Storybook static files" +alias = "bs" +run = "npm run build-storybook" diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03d..fd36f949 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package.json b/package.json index b200decf..90df92d5 100644 --- a/package.json +++ b/package.json @@ -13,72 +13,72 @@ "build-storybook": "storybook build" }, "dependencies": { - "@next/font": "^13.4.19", - "@radix-ui/react-alert-dialog": "^1.0.2", - "@radix-ui/react-dialog": "^1.0.2", - "@radix-ui/react-dropdown-menu": "^2.0.1", - "@radix-ui/react-hover-card": "^1.0.2", - "@radix-ui/react-popover": "^1.0.3", - "@radix-ui/react-radio-group": "^1.1.1", - "@radix-ui/react-select": "^1.1.2", - "@radix-ui/react-slider": "^1.1.1", - "@radix-ui/react-switch": "^1.0.1", - "@radix-ui/react-toast": "^1.1.2", - "@radix-ui/react-toggle-group": "^1.0.1", - "@radix-ui/react-tooltip": "^1.0.3", - "@svgr/webpack": "^6.2.0", - "@tiptap/extension-bubble-menu": "^2.0.3", - "@tiptap/extension-highlight": "^2.0.3", - "@tiptap/extension-link": "^2.0.3", - "@tiptap/extension-mention": "^2.0.3", - "@tiptap/extension-placeholder": "^2.0.3", - "@tiptap/extension-typography": "^2.0.3", - "@tiptap/extension-youtube": "^2.0.3", - "@tiptap/pm": "^2.0.3", - "@tiptap/react": "^2.0.3", - "@tiptap/starter-kit": "^2.0.3", - "@tiptap/suggestion": "2.0.0-beta.91", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "@svgr/webpack": "^8.1.0", + "@tanstack/react-query": "^5.17.19", + "@tiptap/extension-bubble-menu": "^2.1.16", + "@tiptap/extension-highlight": "^2.1.16", + "@tiptap/extension-link": "^2.1.16", + "@tiptap/extension-mention": "^2.1.16", + "@tiptap/extension-placeholder": "^2.1.16", + "@tiptap/extension-typography": "^2.1.16", + "@tiptap/extension-youtube": "^2.1.16", + "@tiptap/pm": "^2.1.16", + "@tiptap/react": "^2.1.16", + "@tiptap/starter-kit": "^2.1.16", + "@tiptap/suggestion": "^2.1.16", "@types/react-bootstrap-typeahead": "^5.1.9", - "axios": "^0.25.0", - "classnames": "^2.3.1", - "cmdk": "^0.2.0", - "cookies-next": "^2.1.1", - "date-fns": "^2.29.3", - "dompurify": "^3.0.4", + "axios": "^1.6.7", + "classnames": "^2.5.1", + "cmdk": "^0.2.1", + "cookies-next": "^4.1.0", + "date-fns": "^3.3.1", + "dompurify": "^3.0.8", "fast-deep-equal": "^3.1.3", "fix-date": "^1.1.6", - "i18next": "^21.6.13", - "i18next-browser-languagedetector": "^6.1.3", - "i18next-http-backend": "^1.3.2", + "i18next": "^23.7.20", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.4.2", "local-storage": "^2.0.0", "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "meyer-reset-scss": "^2.0.4", - "next": "^13.4.19", - "next-i18next": "^10.5.0", + "next": "^14.1.0", + "next-i18next": "^15.1.1", "next-themes": "^0.2.1", - "nuqs": "^1.14.0", + "nuqs": "^1.15.4", "pluralize": "^8.0.0", "react": "^18.2.0", - "react-bootstrap-typeahead": "^6.2.3", + "react-bootstrap-typeahead": "^6.3.2", "react-dom": "^18.2.0", - "react-i18next": "^11.15.5", + "react-i18next": "^14.0.1", "react-infinite-scroll-component": "^6.1.0", "react-linkify": "^1.0.0-alpha", - "react-lite-youtube-embed": "^2.3.52", - "react-scroll": "^1.8.5", - "react-string-replace": "^1.1.0", - "react-use": "^17.4.0", + "react-lite-youtube-embed": "^2.4.0", + "react-scroll": "^1.9.0", + "react-string-replace": "^1.1.1", + "react-use": "^17.4.2", "remixicon-react": "^1.0.0", - "resolve-url-loader": "^5.0.0", - "sanitize-html": "^2.8.1", - "sass": "^1.61.0", + "sanitize-html": "^2.11.0", + "sass": "^1.69.7", "tippy.js": "^6.3.7", "usehooks-ts": "^2.9.1", - "uuid": "^9.0.0", - "valtio": "^1.3.0", - "youtube-api-v3-wrapper": "^2.3.0" + "uuid": "^9.0.1", + "valtio": "^1.13.0", + "youtube-api-v3-wrapper": "^2.3.0", + "zod": "^3.22.4" }, "devDependencies": { "@storybook/addon-essentials": "latest", @@ -90,29 +90,30 @@ "@storybook/nextjs": "latest", "@storybook/react": "latest", "@storybook/testing-library": "latest", - "@types/dompurify": "^3.0.2", - "@types/lodash.clonedeep": "^4.5.6", - "@types/lodash.debounce": "^4.0.6", - "@types/node": "17.0.11", - "@types/pluralize": "^0.0.29", - "@types/react": "17.0.38", - "@types/react-dom": "^17.0.11", - "@types/react-infinite-scroller": "^1.2.2", - "@types/react-linkify": "^1.0.1", - "@types/react-scroll": "^1.8.3", - "@types/sanitize-html": "^2.8.0", - "@types/uuid": "^9.0.0", - "eslint": "8.7.0", - "eslint-config-next": "^13.4.19", - "eslint-plugin-storybook": "^0.6.11", - "eslint-plugin-valtio": "^0.4.1", - "sass-loader": "^13.2.2", + "@types/dompurify": "^3.0.5", + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.debounce": "^4.0.9", + "@types/node": "20.11.5", + "@types/pluralize": "^0.0.33", + "@types/react": "18.2.48", + "@types/react-dom": "18.2.18", + "@types/react-infinite-scroller": "^1.2.5", + "@types/react-linkify": "^1.0.4", + "@types/react-scroll": "^1.8.10", + "@types/sanitize-html": "^2.9.5", + "@types/uuid": "^9.0.7", + "eslint": "8.56.0", + "eslint-config-next": "14.1.0", + "eslint-plugin-storybook": "^0.6.15", + "eslint-plugin-valtio": "^0.6.2", + "sass-loader": "^13.3.3", "storybook": "latest", - "typescript": "^4.5.5" + "typescript": "5.3.3" }, "overrides": { "@tiptap/extension-mention": { - "@tiptap/suggestion": "2.0.0-beta.91" + "@tiptap/suggestion": "^2.1.16" } - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/pages/api/preview/[shortcode].ts b/pages/api/preview/[shortcode].ts deleted file mode 100644 index 12056dc1..00000000 --- a/pages/api/preview/[shortcode].ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next' -import axios from 'axios' - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { - const { shortcode } = req.query - - if (!shortcode || Array.isArray(shortcode)) { - return res.status(400).json({ error: 'Invalid shortcode' }) - } - - try { - const response = await axios({ - method: 'GET', - url: `${process.env.API_URL}/api/v1/parties/${shortcode}/preview`, - responseType: 'arraybuffer', - headers: { - Accept: 'image/png', - }, - }) - - // Set correct content type and caching headers - res.setHeader('Content-Type', 'image/png') - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') - - return res.send(response.data) - } catch (error) { - console.error('Error fetching preview:', error) - return res.status(500).json({ error: 'Failed to fetch preview' }) - } -} diff --git a/styles/globals.scss b/styles/globals.scss index 91ec6462..2cd03d8b 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -1,5 +1,6 @@ @import '~meyer-reset-scss'; @import 'themes.scss'; +@import '../app/styles/error.scss'; html { background-color: var(--background); diff --git a/supervisord/hensei-web.ini b/supervisord/hensei-web.ini new file mode 100644 index 00000000..bf112347 --- /dev/null +++ b/supervisord/hensei-web.ini @@ -0,0 +1,17 @@ +[program:hensei-web] +command=/opt/homebrew/bin/mise run dev +process_name=%(program_name)s +numprocs=1 +directory=/Users/justin/Developer/Granblue/hensei-web +environment=HOME="/Users/justin",NODE_ENV="development",MISE_CONFIG_ROOT="/Users/justin/Developer/Granblue/hensei-web" +autostart=true +autorestart=unexpected +stopsignal=TERM +user=justin +stdout_logfile=/Users/justin/Developer/Granblue/hensei-web/logs/hensei-web.stdout.log +stdout_logfile_maxbytes=1MB +stdout_logfile_backups=10 +stderr_logfile=/Users/justin/Developer/Granblue/hensei-web/logs/hensei-web.stderr.log +stderr_logfile_maxbytes=1MB +stderr_logfile_backups=10 +serverurl=AUTO diff --git a/tsconfig.json b/tsconfig.json index 8c6bf634..14dcba3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,11 @@ "compilerOptions": { "baseUrl": ".", "target": "es2015", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -15,8 +19,24 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "paths": { "~*": ["./*"] } + "paths": { + "~*": [ + "./*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }