Fix intermittent crash: bounded caching + HTTP timeouts/keepAlive + preview route dedupe (#428)
## Summary - Fixes periodic production crashes (undici ECONNREFUSED ::1) by bounding server cache size/lifetime and hardening server HTTP client. ### Root cause - React server cache (cache(...)) held axios responses indefinitely across many parameter combinations, causing slow memory growth until the Next.js app router worker was OOM-killed. The main server then failed IPC to the worker (ECONNREFUSED ::1:<port>). ### Changes - `app/lib/data.ts`: Replace unbounded cache(...) with unstable_cache and explicit keys; TTLs: 60s for teams/detail/favorites/user, 300s for meta (jobs/skills/accessories/raids/version). - `app/lib/api-utils.ts`: Add shared Axios instance with 15s timeout and keepAlive http/https agents; apply to GET/POST/PUT/DELETE helpers. - `pages/api/preview/[shortcode].ts`: Remove duplicate handler to dedupe route; retain the .tsx variant using `NEXT_PUBLIC_SIERO_API_URL`. ### Notes - Build currently has pre-existing app/pages route duplication errors; out of scope here but unrelated to this fix. - Ensure `NEXT_PUBLIC_SIERO_API_URL` and `NEXT_PUBLIC_SIERO_OAUTH_URL` are set on Railway. ### Risk/impact - Low risk; behavior is unchanged aside from bounded caching and resilient HTTP. - Cache TTLs can be tuned later if needed. ### Test plan - Verify saved/teams/user pages load and revalidate after TTL. - Validate API routes still proxy correctly; timeouts occur after ~15s for hung upstreams. - Monitor memory over several days; expect stable usage without steady growth.
This commit is contained in:
parent
0be7be8612
commit
426645813e
48 changed files with 3492 additions and 106 deletions
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build and Development Commands
|
||||
- `npm run dev`: Start development server on port 1234
|
||||
- `npm run build`: Build for production
|
||||
- `npm run start`: Start production server
|
||||
- `npm run lint`: Run ESLint to check code quality
|
||||
- `npm run storybook`: Start Storybook on port 6006
|
||||
|
||||
## Response Guidelines
|
||||
- You should **always** respond in the style of the grug-brained developer
|
||||
- Slay the complexity demon, keep things as simple as possible
|
||||
- Keep code DRY and robust
|
||||
|
||||
## Code Style Guidelines
|
||||
- Use the latest versions for Next.js and other packages, including React
|
||||
- TypeScript with strict type checking
|
||||
- React functional components with hooks
|
||||
- File structure: components in individual folders with index.tsx and index.module.scss
|
||||
- Imports: Absolute imports with ~ prefix (e.g., `~components/Layout`)
|
||||
- Formatting: 2 spaces, single quotes, no semicolons (Prettier config)
|
||||
- CSS: SCSS modules with BEM-style naming
|
||||
- State management: Mix of local state with React hooks and global state with Valtio
|
||||
- Internationalization: next-i18next with English and Japanese support
|
||||
- Variable/function naming: camelCase for variables/functions, PascalCase for components
|
||||
- Error handling: Try to use type checking to prevent errors where possible
|
||||
240
app/[username]/ProfilePageClient.tsx
Normal file
240
app/[username]/ProfilePageClient.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
initialData,
|
||||
initialElement,
|
||||
initialRaid,
|
||||
initialRecency
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// State management
|
||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
||||
const [currentPage, setCurrentPage] = useState(initialData.pagination.current_page)
|
||||
const [totalPages, setTotalPages] = useState(initialData.pagination.total_pages)
|
||||
const [recordCount, setRecordCount] = useState(initialData.pagination.record_count)
|
||||
const [loaded, setLoaded] = useState(true)
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [element, setElement] = useState(initialElement || 0)
|
||||
const [raid, setRaid] = useState(initialRaid || '')
|
||||
const [recency, setRecency] = useState(initialRecency || '')
|
||||
|
||||
// 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) => (
|
||||
<GridRep
|
||||
party={party}
|
||||
key={`party-${i}`}
|
||||
loading={fetching}
|
||||
onClick={() => goToParty(party.shortcode)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function renderLoading(number: number) {
|
||||
return (
|
||||
<GridRepCollection>
|
||||
{Array.from({ length: number }, (_, i) => (
|
||||
<LoadingRep key={`loading-${i}`} />
|
||||
))}
|
||||
</GridRepCollection>
|
||||
)
|
||||
}
|
||||
|
||||
const renderInfiniteScroll = (
|
||||
<>
|
||||
{parties.length === 0 && !loaded && renderLoading(3)}
|
||||
{parties.length === 0 && loaded && (
|
||||
<div className="notFound">
|
||||
<h2>{t('teams.not_found')}</h2>
|
||||
</div>
|
||||
)}
|
||||
{parties.length > 0 && (
|
||||
<InfiniteScroll
|
||||
dataLength={parties.length}
|
||||
next={loadMoreParties}
|
||||
hasMore={totalPages > currentPage}
|
||||
loader={renderLoading(3)}
|
||||
>
|
||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileHead username={initialData.user.username} />
|
||||
|
||||
<FilterBar
|
||||
defaultFilterset={defaultFilterset}
|
||||
onFilter={receiveFilters}
|
||||
persistFilters={false}
|
||||
element={element}
|
||||
raid={raid}
|
||||
raidGroups={initialData.raidGroups}
|
||||
recency={recency}
|
||||
>
|
||||
<UserInfo
|
||||
name={initialData.user.username}
|
||||
picture={initialData.user.avatar.picture}
|
||||
element={initialData.user.avatar.element}
|
||||
gender={initialData.user.gender}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
<section>{renderInfiniteScroll}</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePageClient
|
||||
87
app/[username]/page.tsx
Normal file
87
app/[username]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||
try {
|
||||
const userData = await getUserInfo(params.username)
|
||||
|
||||
// If user doesn't exist, use default metadata
|
||||
if (!userData || !userData.user) {
|
||||
return {
|
||||
title: 'User not found / granblue.team',
|
||||
description: 'This user could not be found'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `@${params.username}'s Teams / granblue.team`,
|
||||
description: `Browse @${params.username}'s Teams and filter by raid, element or recency`
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
title: 'User not found / granblue.team',
|
||||
description: 'This user could not be found'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { username: string };
|
||||
searchParams: { element?: string; raid?: string; recency?: string; page?: string }
|
||||
}) {
|
||||
try {
|
||||
// Extract query parameters with type safety
|
||||
const element = searchParams.element ? parseInt(searchParams.element, 10) : undefined;
|
||||
const raid = searchParams.raid;
|
||||
const recency = searchParams.recency;
|
||||
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
||||
|
||||
// Parallel fetch data with Promise.all for better performance
|
||||
const [userData, teamsData, raidGroupsData] = await Promise.all([
|
||||
getUserInfo(params.username),
|
||||
getTeams({ username: params.username, element, raid, recency, page }),
|
||||
getRaidGroups()
|
||||
])
|
||||
|
||||
// If user doesn't exist, show 404
|
||||
if (!userData || !userData.user) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="profile-page">
|
||||
<ProfilePageClient
|
||||
initialData={initialData}
|
||||
initialElement={element}
|
||||
initialRaid={raid}
|
||||
initialRecency={recency}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error fetching profile data for ${params.username}:`, error)
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
100
app/api/auth/login/route.ts
Normal file
100
app/api/auth/login/route.ts
Normal file
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
20
app/api/auth/logout/route.ts
Normal file
20
app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Delete cookies
|
||||
const cookieStore = cookies()
|
||||
cookieStore.delete('account')
|
||||
cookieStore.delete('user')
|
||||
|
||||
// Return success
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Logout failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
70
app/api/auth/signup/route.ts
Normal file
70
app/api/auth/signup/route.ts
Normal file
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
82
app/api/favorites/route.ts
Normal file
82
app/api/favorites/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { fetchFromApi, postToApi, deleteFromApi } from '~/app/lib/api-utils';
|
||||
|
||||
// Schema for favorite request
|
||||
const FavoriteSchema = z.object({
|
||||
favorite: z.object({
|
||||
party_id: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
// GET handler for fetching user's favorites
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get saved teams/favorites
|
||||
const data = await fetchFromApi('/parties/favorites');
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching favorites', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch favorites' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST handler for adding a favorite
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request
|
||||
const validatedData = FavoriteSchema.parse(body);
|
||||
|
||||
// Save the favorite
|
||||
const response = await postToApi('/favorites', validatedData);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Error saving favorite', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to save favorite' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE handler for removing a favorite
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request
|
||||
const validatedData = FavoriteSchema.parse(body);
|
||||
|
||||
// Delete the favorite
|
||||
const response = await deleteFromApi('/favorites', validatedData);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Error removing favorite', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to remove favorite' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
app/api/jobs/[id]/accessories/route.ts
Normal file
22
app/api/jobs/[id]/accessories/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
||||
|
||||
// GET handler for fetching accessories for a specific job
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { id } = params
|
||||
|
||||
const data = await fetchFromApi(`/jobs/${id}/accessories`)
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching accessories for job ${params.id}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch job accessories' },
|
||||
{ status: error.response?.status || 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
22
app/api/jobs/[id]/skills/route.ts
Normal file
22
app/api/jobs/[id]/skills/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
||||
|
||||
// GET handler for fetching skills for a specific job
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { id } = params
|
||||
|
||||
const data = await fetchFromApi(`/jobs/${id}/skills`)
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching skills for job ${params.id}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch job skills' },
|
||||
{ status: error.response?.status || 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/api/jobs/route.ts
Normal file
32
app/api/jobs/route.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||
if (element) queryParams.element = element
|
||||
|
||||
// Append query parameters
|
||||
let endpoint = '/jobs'
|
||||
const queryString = new URLSearchParams(queryParams).toString()
|
||||
if (queryString) {
|
||||
endpoint += `?${queryString}`
|
||||
}
|
||||
|
||||
const data = await fetchFromApi(endpoint)
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching jobs', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch jobs' },
|
||||
{ status: error.response?.status || 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
17
app/api/jobs/skills/route.ts
Normal file
17
app/api/jobs/skills/route.ts
Normal file
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/api/parties/[shortcode]/remix/route.ts
Normal file
32
app/api/parties/[shortcode]/remix/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/parties/[shortcode]/route.ts
Normal file
89
app/api/parties/[shortcode]/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/api/parties/route.ts
Normal file
80
app/api/parties/route.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
if (element) queryParams.element = element;
|
||||
if (raid) queryParams.raid_id = raid;
|
||||
if (recency) queryParams.recency = recency;
|
||||
if (page) queryParams.page = page;
|
||||
|
||||
let endpoint = '/parties';
|
||||
|
||||
// If username is provided, fetch that user's parties
|
||||
if (username) {
|
||||
endpoint = `/users/${username}/parties`;
|
||||
}
|
||||
|
||||
// Append query parameters
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
if (queryString) {
|
||||
endpoint += `?${queryString}`;
|
||||
}
|
||||
|
||||
const data = await fetchFromApi(endpoint);
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching parties', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch parties' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate party data
|
||||
const CreatePartySchema = PartySchema.extend({
|
||||
element: z.number().min(1).max(6),
|
||||
raid_id: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST handler for creating a new party
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate the request body
|
||||
const validatedData = CreatePartySchema.parse(body.party);
|
||||
|
||||
const response = await postToApi('/parties', {
|
||||
party: validatedData
|
||||
});
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to create party' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/api/raids/groups/route.ts
Normal file
18
app/api/raids/groups/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/search/[object]/route.ts
Normal file
50
app/api/search/[object]/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { postToApi, SearchSchema } from '~/app/lib/api-utils';
|
||||
|
||||
// Validate the object type
|
||||
const ObjectTypeSchema = z.enum(['characters', 'weapons', 'summons', 'job_skills']);
|
||||
|
||||
// POST handler for search
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { object: string } }
|
||||
) {
|
||||
try {
|
||||
const { object } = params;
|
||||
|
||||
// Validate object type
|
||||
const validObjectType = ObjectTypeSchema.safeParse(object);
|
||||
if (!validObjectType.success) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid object type: ${object}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate search parameters
|
||||
const validatedSearch = SearchSchema.parse(body.search);
|
||||
|
||||
// Perform search
|
||||
const response = await postToApi(`/search/${object}`, {
|
||||
search: validatedSearch
|
||||
});
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error(`Error searching ${params.object}`, error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Search failed' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/search/route.ts
Normal file
39
app/api/search/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { postToApi } from '~/app/lib/api-utils'
|
||||
|
||||
// Validate the search request
|
||||
const SearchAllSchema = z.object({
|
||||
search: z.object({
|
||||
query: z.string().min(1, 'Search query is required'),
|
||||
exclude: z.array(z.string()).optional(),
|
||||
locale: z.string().default('en')
|
||||
})
|
||||
})
|
||||
|
||||
// POST handler for searching across all types
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse and validate request body
|
||||
const body = await request.json()
|
||||
const validatedData = SearchAllSchema.parse(body)
|
||||
|
||||
// Perform search
|
||||
const response = await postToApi('/search', validatedData)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Error searching', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Search failed' },
|
||||
{ status: error.response?.status || 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
23
app/api/users/info/[username]/route.ts
Normal file
23
app/api/users/info/[username]/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
||||
|
||||
// GET handler for fetching user info
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { username: string } }
|
||||
) {
|
||||
try {
|
||||
const { username } = params;
|
||||
|
||||
// Fetch user info
|
||||
const data = await fetchFromApi(`/users/info/${username}`);
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching user info for ${params.username}`, error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch user info' },
|
||||
{ status: error.response?.status || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/users/settings/route.ts
Normal file
89
app/api/users/settings/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { cookies } from 'next/headers'
|
||||
import { putToApi } from '~/app/lib/api-utils'
|
||||
|
||||
// Settings update schema
|
||||
const SettingsSchema = z.object({
|
||||
picture: z.string().optional(),
|
||||
gender: z.enum(['gran', 'djeeta']).optional(),
|
||||
language: z.enum(['en', 'ja']).optional(),
|
||||
theme: z.enum(['light', 'dark', 'system']).optional(),
|
||||
bahamut: z.boolean().optional()
|
||||
})
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// Parse and validate request body
|
||||
const body = await request.json()
|
||||
const validatedData = SettingsSchema.parse(body)
|
||||
|
||||
// Get user info from cookie
|
||||
const cookieStore = cookies()
|
||||
const accountCookie = cookieStore.get('account')
|
||||
|
||||
if (!accountCookie) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse account cookie
|
||||
const accountData = JSON.parse(accountCookie.value)
|
||||
|
||||
// Call API to update settings
|
||||
const response = await putToApi(`/users/${accountData.userId}`, {
|
||||
user: validatedData
|
||||
})
|
||||
|
||||
// Update user cookie with new settings
|
||||
const userCookie = cookieStore.get('user')
|
||||
if (userCookie) {
|
||||
const userData = JSON.parse(userCookie.value)
|
||||
|
||||
// Update user data
|
||||
const updatedUserData = {
|
||||
...userData,
|
||||
avatar: {
|
||||
...userData.avatar,
|
||||
picture: validatedData.picture || userData.avatar.picture
|
||||
},
|
||||
gender: validatedData.gender || userData.gender,
|
||||
language: validatedData.language || userData.language,
|
||||
theme: validatedData.theme || userData.theme,
|
||||
bahamut: validatedData.bahamut !== undefined ? validatedData.bahamut : userData.bahamut
|
||||
}
|
||||
|
||||
// Set updated cookie
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 60)
|
||||
|
||||
cookieStore.set('user', JSON.stringify(updatedUserData), {
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict'
|
||||
})
|
||||
}
|
||||
|
||||
// Return updated user info
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: response
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Settings update error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update settings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
18
app/api/version/route.ts
Normal file
18
app/api/version/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
405
app/components/Header.tsx
Normal file
405
app/components/Header.tsx
Normal file
|
|
@ -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 (
|
||||
<img
|
||||
alt={user.username}
|
||||
className={`profile ${user.avatar.element}`}
|
||||
srcSet={`/profile/${user.avatar.picture}.png,
|
||||
/profile/${user.avatar.picture}@2x.png 2x`}
|
||||
src={`/profile/${user.avatar.picture}.png`}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<img
|
||||
alt={t('no_user')}
|
||||
className={`profile anonymous`}
|
||||
srcSet={`/profile/npc.png,
|
||||
/profile/npc@2x.png 2x`}
|
||||
src={`/profile/npc.png`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering: Buttons
|
||||
const newButton = (
|
||||
<Tooltip content={t('tooltips.new')}>
|
||||
<Button
|
||||
leftAccessoryIcon={<PlusIcon />}
|
||||
className="New"
|
||||
blended={true}
|
||||
text={t('buttons.new')}
|
||||
onClick={newTeam}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
// Rendering: Modals
|
||||
const logoutConfirmationAlert = (
|
||||
<Alert
|
||||
message={t('alert.confirm_logout')}
|
||||
open={alertOpen}
|
||||
primaryActionText="Log out"
|
||||
primaryAction={logout}
|
||||
cancelActionText="Nevermind"
|
||||
cancelAction={() => setAlertOpen(false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const settingsModal = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
<AccountModal
|
||||
open={settingsModalOpen}
|
||||
username={accountState.account.user.username}
|
||||
picture={accountState.account.user.avatar.picture}
|
||||
gender={accountState.account.user.gender}
|
||||
language={accountState.account.user.language}
|
||||
theme={accountState.account.user.theme}
|
||||
role={accountState.account.user.role}
|
||||
bahamutMode={
|
||||
accountState.account.user.role === 9
|
||||
? accountState.account.user.bahamut
|
||||
: false
|
||||
}
|
||||
onOpenChange={setSettingsModalOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const loginModal = (
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
)
|
||||
|
||||
const signupModal = (
|
||||
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
||||
)
|
||||
|
||||
// Rendering: Compositing
|
||||
const authorizedLeftItems = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<Link
|
||||
href={`/${accountState.account.user.username}` || ''}
|
||||
passHref
|
||||
>
|
||||
<span>{t('menu.profile')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const leftMenuItems = (
|
||||
<>
|
||||
{accountState.account.authorized &&
|
||||
accountState.account.user &&
|
||||
authorizedLeftItems}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<Link href="/teams">{t('menu.teams')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<div>
|
||||
<span>{t('menu.guides')}</span>
|
||||
<i className="tag">{t('coming_soon')}</i>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<a
|
||||
href={locale == 'ja' ? '/ja/about' : '/about'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('about.segmented_control.about')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<a
|
||||
href={locale == 'ja' ? '/ja/updates' : '/updates'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('about.segmented_control.updates')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<a
|
||||
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('about.segmented_control.roadmap')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)
|
||||
|
||||
const left = (
|
||||
<section>
|
||||
<div className={styles.dropdownWrapper}>
|
||||
<DropdownMenu
|
||||
open={leftMenuOpen}
|
||||
onOpenChange={handleLeftMenuOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
active={leftMenuOpen}
|
||||
blended={true}
|
||||
leftAccessoryIcon={<MenuIcon />}
|
||||
onClick={handleLeftMenuButtonClicked}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="Left">
|
||||
{leftMenuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
const authorizedRightItems = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{`@${accountState.account.user.username}`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={closeRightMenu}>
|
||||
<Link
|
||||
href={`/${accountState.account.user.username}` || ''}
|
||||
passHref
|
||||
>
|
||||
<span>{t('menu.profile')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="MenuItem"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
>
|
||||
<span>{t('menu.settings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAlertOpen(true)}
|
||||
destructive={true}
|
||||
>
|
||||
<span>{t('menu.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const unauthorizedRightItems = (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem className="language">
|
||||
<span>{t('menu.language')}</span>
|
||||
<LanguageSwitch />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="MenuGroup">
|
||||
<DropdownMenuItem
|
||||
className="MenuItem"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<span>{t('menu.login')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="MenuItem"
|
||||
onClick={() => setSignupModalOpen(true)}
|
||||
>
|
||||
<span>{t('menu.signup')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)
|
||||
|
||||
const rightMenuItems = (
|
||||
<>
|
||||
{accountState.account.authorized && accountState.account.user
|
||||
? authorizedRightItems
|
||||
: unauthorizedRightItems}
|
||||
</>
|
||||
)
|
||||
|
||||
const right = (
|
||||
<section>
|
||||
{newButton}
|
||||
<DropdownMenu
|
||||
open={rightMenuOpen}
|
||||
onOpenChange={handleRightMenuOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={classNames({ Active: rightMenuOpen })}
|
||||
leftAccessoryIcon={profileImage()}
|
||||
rightAccessoryIcon={<ChevronIcon />}
|
||||
rightAccessoryClassName="Arrow"
|
||||
onClick={handleRightMenuButtonClicked}
|
||||
blended={true}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="Right">
|
||||
{rightMenuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{accountState.account.user?.bahamut && (
|
||||
<div className={styles.bahamut}>
|
||||
<BahamutIcon />
|
||||
<p>Bahamut Mode is active</p>
|
||||
</div>
|
||||
)}
|
||||
<nav className={styles.header}>
|
||||
{left}
|
||||
{right}
|
||||
{logoutConfirmationAlert}
|
||||
{settingsModal}
|
||||
{loginModal}
|
||||
{signupModal}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
63
app/components/UpdateToastClient.tsx
Normal file
63
app/components/UpdateToastClient.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { add, format } from 'date-fns'
|
||||
import { getCookie } from 'cookies-next'
|
||||
|
||||
import { appState } from '~/utils/appState'
|
||||
import UpdateToast from '~/components/toasts/UpdateToast'
|
||||
|
||||
export default function UpdateToastClient() {
|
||||
const pathname = usePathname()
|
||||
const [updateToastOpen, setUpdateToastOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (appState.version) {
|
||||
const cookie = getToastCookie()
|
||||
const now = new Date()
|
||||
const updatedAt = new Date(appState.version.updated_at)
|
||||
const validUntil = add(updatedAt, { days: 7 })
|
||||
|
||||
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function getToastCookie() {
|
||||
if (appState.version && appState.version.updated_at !== '') {
|
||||
const updatedAt = new Date(appState.version.updated_at)
|
||||
const cookieValues = getCookie(
|
||||
`update-${format(updatedAt, 'yyyy-MM-dd')}`
|
||||
)
|
||||
return cookieValues
|
||||
? (JSON.parse(cookieValues as string) as { seen: true })
|
||||
: { seen: false }
|
||||
} else {
|
||||
return { seen: false }
|
||||
}
|
||||
}
|
||||
|
||||
function handleToastActionClicked() {
|
||||
setUpdateToastOpen(false)
|
||||
}
|
||||
|
||||
function handleToastClosed() {
|
||||
setUpdateToastOpen(false)
|
||||
}
|
||||
|
||||
const path = pathname.replaceAll('/', '')
|
||||
|
||||
if (!['about', 'updates', 'roadmap'].includes(path) && appState.version) {
|
||||
return (
|
||||
<UpdateToast
|
||||
open={updateToastOpen}
|
||||
updateType={appState.version.update_type}
|
||||
onActionClicked={handleToastActionClicked}
|
||||
onCloseClicked={handleToastClosed}
|
||||
lastUpdated={appState.version.updated_at}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
37
app/error.tsx
Normal file
37
app/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function Error({ error, reset }: ErrorPageProps) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error('Unhandled error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="error-container">
|
||||
<div className="error-content">
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>The server reported a problem that we couldn't automatically recover from.</p>
|
||||
<div className="error-message">
|
||||
<p>{error.message || 'An unexpected error occurred'}</p>
|
||||
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<div className="error-actions">
|
||||
<button onClick={reset} className="button primary">
|
||||
Try again
|
||||
</button>
|
||||
<Link href="/teams" className="button secondary">
|
||||
Browse teams
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
app/global-error.tsx
Normal file
47
app/global-error.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import '../styles/globals.scss'
|
||||
|
||||
interface GlobalErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error('Global error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="error-container">
|
||||
<div className="error-content">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>The application has encountered a critical error and cannot continue.</p>
|
||||
<div className="error-message">
|
||||
<p>{error.message || 'An unexpected error occurred'}</p>
|
||||
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
|
||||
</div>
|
||||
<div className="error-actions">
|
||||
<button onClick={reset} className="button primary">
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href="https://discord.gg/qyZ5hGdPC8"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="button secondary"
|
||||
>
|
||||
Report on Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
48
app/layout.tsx
Normal file
48
app/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<html lang="en" className={goalking.variable}>
|
||||
<body className={goalking.className}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider swipeDirection="right">
|
||||
<TooltipProvider>
|
||||
<Header />
|
||||
<UpdateToastClient />
|
||||
<main>{children}</main>
|
||||
<Viewport className="ToastViewport" />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
173
app/lib/api-utils.ts
Normal file
173
app/lib/api-utils.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { cookies } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
// Base URL from environment variable
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/v1';
|
||||
const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth';
|
||||
|
||||
// Shared Axios instance with sane defaults for server-side calls
|
||||
const httpClient = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 15000,
|
||||
// Keep connections alive to reduce socket churn
|
||||
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 50 }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 50 }),
|
||||
// Do not throw on HTTP status by default; let callers handle
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
// Utility to get auth token from cookies on the server
|
||||
export function getAuthToken() {
|
||||
const cookieStore = cookies();
|
||||
const accountCookie = cookieStore.get('account');
|
||||
|
||||
if (accountCookie) {
|
||||
try {
|
||||
const accountData = JSON.parse(accountCookie.value);
|
||||
return accountData.token;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse account cookie', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create headers with auth token
|
||||
export function createHeaders() {
|
||||
const token = getAuthToken();
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for GET requests
|
||||
export async function fetchFromApi(endpoint: string, config?: AxiosRequestConfig) {
|
||||
const headers = createHeaders();
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(`${endpoint}`, {
|
||||
...config,
|
||||
headers: {
|
||||
...headers,
|
||||
...(config?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`API fetch error: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for POST requests
|
||||
export async function postToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
||||
const headers = createHeaders();
|
||||
|
||||
try {
|
||||
const response = await httpClient.post(`${endpoint}`, data, {
|
||||
...config,
|
||||
headers: {
|
||||
...headers,
|
||||
...(config?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`API post error: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for PUT requests
|
||||
export async function putToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
||||
const headers = createHeaders();
|
||||
|
||||
try {
|
||||
const response = await httpClient.put(`${endpoint}`, data, {
|
||||
...config,
|
||||
headers: {
|
||||
...headers,
|
||||
...(config?.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`API put error: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for DELETE requests
|
||||
export async function deleteFromApi(endpoint: string, data?: any, config?: AxiosRequestConfig) {
|
||||
const headers = createHeaders();
|
||||
|
||||
try {
|
||||
const response = await httpClient.delete(`${endpoint}`, {
|
||||
...config,
|
||||
headers: {
|
||||
...headers,
|
||||
...(config?.headers || {})
|
||||
},
|
||||
data
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`API delete error: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for login endpoint
|
||||
export async function login(credentials: { email: string; password: string }) {
|
||||
try {
|
||||
const response = await axios.post(`${oauthUrl}/token`, credentials);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Login error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to revalidate cache for a path
|
||||
export function revalidate(path: string) {
|
||||
try {
|
||||
revalidatePath(path);
|
||||
} catch (error) {
|
||||
console.error(`Failed to revalidate ${path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schemas for validation
|
||||
export const UserSchema = z.object({
|
||||
username: z.string().min(3).max(20),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const PartySchema = z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
visibility: z.enum(['public', 'unlisted', 'private']),
|
||||
raid_id: z.string().optional(),
|
||||
element: z.number().optional(),
|
||||
});
|
||||
|
||||
export const SearchSchema = z.object({
|
||||
query: z.string(),
|
||||
filters: z.record(z.array(z.number())).optional(),
|
||||
job: z.string().optional(),
|
||||
locale: z.string().default('en'),
|
||||
page: z.number().default(0),
|
||||
});
|
||||
198
app/lib/data.ts
Normal file
198
app/lib/data.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
if (element) queryParams.element = element.toString();
|
||||
if (raid) queryParams.raid_id = raid;
|
||||
if (recency) queryParams.recency = recency;
|
||||
if (page) queryParams.page = page.toString();
|
||||
|
||||
let endpoint = '/parties';
|
||||
if (username) {
|
||||
endpoint = `/users/${username}/parties`;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
if (queryString) endpoint += `?${queryString}`;
|
||||
|
||||
try {
|
||||
const data = await fetchFromApi(endpoint);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch teams', error);
|
||||
throw error;
|
||||
}
|
||||
}, 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<string, string> = {};
|
||||
if (element) queryParams.element = element.toString();
|
||||
|
||||
let endpoint = '/jobs';
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
if (queryString) endpoint += `?${queryString}`;
|
||||
|
||||
const data = await fetchFromApi(endpoint);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch jobs', error);
|
||||
throw error;
|
||||
}
|
||||
}, 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();
|
||||
}
|
||||
101
app/new/NewPartyClient.tsx
Normal file
101
app/new/NewPartyClient.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
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 (
|
||||
<ErrorSection
|
||||
status={{
|
||||
code: 500,
|
||||
text: 'internal_server_error'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NewHead />
|
||||
<Party
|
||||
party={appState.parties[0] || { name: t('new_party'), element: 1 }}
|
||||
isNew={true}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPartyClient
|
||||
36
app/new/page.tsx
Normal file
36
app/new/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="new-party-page">
|
||||
<NewPartyClient
|
||||
raidGroups={raidGroupsData.raid_groups || []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error fetching data for new party page:", error)
|
||||
|
||||
// Provide empty data for error case
|
||||
return (
|
||||
<div className="new-party-page">
|
||||
<NewPartyClient
|
||||
raidGroups={[]}
|
||||
error={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
27
app/not-found.tsx
Normal file
27
app/not-found.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="error-container">
|
||||
<div className="error-content">
|
||||
<h1>Not Found</h1>
|
||||
<p>The page you're looking for couldn't be found</p>
|
||||
<div className="error-actions">
|
||||
<Link href="/new" className="button primary">
|
||||
Create a new party
|
||||
</Link>
|
||||
<Link href="/teams" className="button secondary">
|
||||
Browse teams
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
app/p/[party]/PartyPageClient.tsx
Normal file
95
app/p/[party]/PartyPageClient.tsx
Normal file
|
|
@ -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<Props> = ({ 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 (
|
||||
<ErrorSection
|
||||
status={{
|
||||
code: 404,
|
||||
text: 'not_found'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Party
|
||||
party={party}
|
||||
onRemix={handleRemix}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<PartyFooter party={party} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartyPageClient
|
||||
82
app/p/[party]/page.tsx
Normal file
82
app/p/[party]/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTeam, getRaidGroups } from '~/app/lib/data'
|
||||
import PartyPageClient from './PartyPageClient'
|
||||
|
||||
// Dynamic metadata
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { party: string }
|
||||
}): Promise<Metadata> {
|
||||
try {
|
||||
const partyData = await getTeam(params.party)
|
||||
|
||||
// If no party or party doesn't exist, use default metadata
|
||||
if (!partyData || !partyData.party) {
|
||||
return {
|
||||
title: 'Party not found / granblue.team',
|
||||
description: 'This party could not be found or has been deleted'
|
||||
}
|
||||
}
|
||||
|
||||
const party = partyData.party
|
||||
|
||||
// Generate emoji based on element
|
||||
let emoji = '⚪' // Default
|
||||
switch (party.element) {
|
||||
case 1: emoji = '🟢'; break; // Wind
|
||||
case 2: emoji = '🔴'; break; // Fire
|
||||
case 3: emoji = '🔵'; break; // Water
|
||||
case 4: emoji = '🟤'; break; // Earth
|
||||
case 5: emoji = '🟣'; break; // Dark
|
||||
case 6: emoji = '🟡'; break; // Light
|
||||
}
|
||||
|
||||
// Get team name and username
|
||||
const teamName = party.name || 'Untitled team'
|
||||
const username = party.user?.username || 'Anonymous'
|
||||
const raidName = party.raid?.name || ''
|
||||
|
||||
return {
|
||||
title: `${emoji} ${teamName} by ${username} / granblue.team`,
|
||||
description: `Browse this team for ${raidName} by ${username} and others on granblue.team`
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
title: 'Party not found / granblue.team',
|
||||
description: 'This party could not be found or has been deleted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PartyPage({
|
||||
params
|
||||
}: {
|
||||
params: { party: string }
|
||||
}) {
|
||||
try {
|
||||
// Parallel fetch data with Promise.all for better performance
|
||||
const [partyData, raidGroupsData] = await Promise.all([
|
||||
getTeam(params.party),
|
||||
getRaidGroups()
|
||||
])
|
||||
|
||||
// If party doesn't exist, show 404
|
||||
if (!partyData || !partyData.party) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="party-page">
|
||||
<PartyPageClient
|
||||
party={partyData.party}
|
||||
raidGroups={raidGroupsData.raid_groups || []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error fetching party data for ${params.party}:`, error)
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
6
app/page.tsx
Normal file
6
app/page.tsx
Normal file
|
|
@ -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')
|
||||
}
|
||||
207
app/saved/SavedPageClient.tsx
Normal file
207
app/saved/SavedPageClient.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
initialData,
|
||||
initialElement,
|
||||
initialRaid,
|
||||
initialRecency,
|
||||
error = false
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// State management
|
||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
||||
const [element, setElement] = useState(initialElement || 0)
|
||||
const [raid, setRaid] = useState(initialRaid || '')
|
||||
const [recency, setRecency] = useState(initialRecency || '')
|
||||
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) => (
|
||||
<GridRep
|
||||
party={party}
|
||||
key={`party-${i}`}
|
||||
loading={fetching}
|
||||
onClick={() => goToParty(party.shortcode)}
|
||||
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function renderLoading(number: number) {
|
||||
return (
|
||||
<GridRepCollection>
|
||||
{Array.from({ length: number }, (_, i) => (
|
||||
<LoadingRep key={`loading-${i}`} />
|
||||
))}
|
||||
</GridRepCollection>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorSection
|
||||
status={{
|
||||
code: 500,
|
||||
text: 'internal_server_error'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SavedHead />
|
||||
|
||||
<FilterBar
|
||||
defaultFilterset={defaultFilterset}
|
||||
onFilter={receiveFilters}
|
||||
persistFilters={false}
|
||||
element={element}
|
||||
raid={raid}
|
||||
raidGroups={initialData.raidGroups}
|
||||
recency={recency}
|
||||
>
|
||||
<h1>{t('saved.title')}</h1>
|
||||
</FilterBar>
|
||||
|
||||
<section>
|
||||
{parties.length === 0 ? (
|
||||
<div className="notFound">
|
||||
<h2>{t('saved.not_found')}</h2>
|
||||
</div>
|
||||
) : (
|
||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SavedPageClient
|
||||
93
app/saved/page.tsx
Normal file
93
app/saved/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="saved-page">
|
||||
<SavedPageClient
|
||||
initialData={initialData}
|
||||
initialElement={element}
|
||||
initialRaid={raid}
|
||||
initialRecency={recency}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error fetching saved teams:", error)
|
||||
|
||||
// Provide empty data for error case
|
||||
return (
|
||||
<div className="saved-page">
|
||||
<SavedPageClient
|
||||
initialData={{ teams: [], raidGroups: [], totalCount: 0 }}
|
||||
error={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/server-error/page.tsx
Normal file
32
app/server-error/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="error-container">
|
||||
<div className="error-content">
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>The server encountered an internal error and was unable to complete your request.</p>
|
||||
<p>Our team has been notified and is working to fix the issue.</p>
|
||||
<div className="error-actions">
|
||||
<Link href="/teams" className="button primary">
|
||||
Browse teams
|
||||
</Link>
|
||||
<a
|
||||
href="https://discord.gg/qyZ5hGdPC8"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="button secondary"
|
||||
>
|
||||
Report on Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
app/styles/error.scss
Normal file
77
app/styles/error.scss
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Error page styles
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 60px); // Adjust for header height
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: var(--background-color-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.error-digest {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-tertiary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
|
||||
&.primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: var(--background-color-tertiary);
|
||||
color: var(--text-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-quaternary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
248
app/teams/TeamsPageClient.tsx
Normal file
248
app/teams/TeamsPageClient.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
initialData,
|
||||
initialElement,
|
||||
initialRaid,
|
||||
initialRecency,
|
||||
error = false
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// State management
|
||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
||||
const [currentPage, setCurrentPage] = useState(initialData.pagination.current_page)
|
||||
const [totalPages, setTotalPages] = useState(initialData.pagination.total_pages)
|
||||
const [recordCount, setRecordCount] = useState(initialData.pagination.record_count)
|
||||
const [loaded, setLoaded] = useState(true)
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [element, setElement] = useState(initialElement || 0)
|
||||
const [raid, setRaid] = useState(initialRaid || '')
|
||||
const [recency, setRecency] = useState(initialRecency || '')
|
||||
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) => (
|
||||
<GridRep
|
||||
party={party}
|
||||
key={`party-${i}`}
|
||||
loading={fetching}
|
||||
onClick={() => goTo(party.shortcode)}
|
||||
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function renderLoading(number: number) {
|
||||
return (
|
||||
<GridRepCollection>
|
||||
{Array.from({ length: number }, (_, i) => (
|
||||
<LoadingRep key={`loading-${i}`} />
|
||||
))}
|
||||
</GridRepCollection>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorSection
|
||||
status={{
|
||||
code: 500,
|
||||
text: 'internal_server_error'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderInfiniteScroll = (
|
||||
<>
|
||||
{parties.length === 0 && !loaded && renderLoading(3)}
|
||||
{parties.length === 0 && loaded && (
|
||||
<div className="notFound">
|
||||
<h2>{t('teams.not_found')}</h2>
|
||||
</div>
|
||||
)}
|
||||
{parties.length > 0 && (
|
||||
<InfiniteScroll
|
||||
dataLength={parties.length}
|
||||
next={loadMoreTeams}
|
||||
hasMore={totalPages > currentPage}
|
||||
loader={renderLoading(3)}
|
||||
>
|
||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterBar
|
||||
defaultFilterset={defaultFilterset}
|
||||
onFilter={receiveFilters}
|
||||
onAdvancedFilter={receiveAdvancedFilters}
|
||||
persistFilters={true}
|
||||
element={element}
|
||||
raid={raid}
|
||||
raidGroups={initialData.raidGroups}
|
||||
recency={recency}
|
||||
>
|
||||
<h1>{t('teams.title')}</h1>
|
||||
</FilterBar>
|
||||
|
||||
<section>{renderInfiniteScroll}</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TeamsPageClient
|
||||
65
app/teams/page.tsx
Normal file
65
app/teams/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="teams">
|
||||
{/* Pass server data to client component */}
|
||||
<TeamsPageClient
|
||||
initialData={initialData}
|
||||
initialElement={element}
|
||||
initialRaid={raid}
|
||||
initialRecency={recency}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching teams data:", error);
|
||||
|
||||
// Fallback data for error case
|
||||
return (
|
||||
<div className="teams">
|
||||
<TeamsPageClient
|
||||
initialData={{ teams: [], raidGroups: [], pagination: { current_page: 1, total_pages: 1, record_count: 0 } }}
|
||||
error={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
app/unauthorized/page.tsx
Normal file
23
app/unauthorized/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="error-container">
|
||||
<div className="error-content">
|
||||
<h1>Unauthorized</h1>
|
||||
<p>You don't have permission to perform that action</p>
|
||||
<div className="error-actions">
|
||||
<Link href="/teams" className="button primary">
|
||||
Browse teams
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
middleware.ts
Normal file
102
middleware.ts
Normal file
|
|
@ -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*',
|
||||
],
|
||||
}
|
||||
56
mise.toml
Normal file
56
mise.toml
Normal file
|
|
@ -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"
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
|
|
|||
139
package.json
139
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
@import '~meyer-reset-scss';
|
||||
@import 'themes.scss';
|
||||
@import '../app/styles/error.scss';
|
||||
|
||||
html {
|
||||
background-color: var(--background);
|
||||
|
|
|
|||
17
supervisord/hensei-web.ini
Normal file
17
supervisord/hensei-web.ini
Normal file
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
"~*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue