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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// 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"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/font": "^13.4.19",
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dialog": "^1.0.2",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.1",
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@radix-ui/react-hover-card": "^1.0.2",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-popover": "^1.0.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-radio-group": "^1.1.1",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-select": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-slider": "^1.1.1",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-switch": "^1.0.1",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-toast": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||||
"@radix-ui/react-toggle-group": "^1.0.1",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@radix-ui/react-tooltip": "^1.0.3",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@svgr/webpack": "^6.2.0",
|
"@tanstack/react-query": "^5.17.19",
|
||||||
"@tiptap/extension-bubble-menu": "^2.0.3",
|
"@tiptap/extension-bubble-menu": "^2.1.16",
|
||||||
"@tiptap/extension-highlight": "^2.0.3",
|
"@tiptap/extension-highlight": "^2.1.16",
|
||||||
"@tiptap/extension-link": "^2.0.3",
|
"@tiptap/extension-link": "^2.1.16",
|
||||||
"@tiptap/extension-mention": "^2.0.3",
|
"@tiptap/extension-mention": "^2.1.16",
|
||||||
"@tiptap/extension-placeholder": "^2.0.3",
|
"@tiptap/extension-placeholder": "^2.1.16",
|
||||||
"@tiptap/extension-typography": "^2.0.3",
|
"@tiptap/extension-typography": "^2.1.16",
|
||||||
"@tiptap/extension-youtube": "^2.0.3",
|
"@tiptap/extension-youtube": "^2.1.16",
|
||||||
"@tiptap/pm": "^2.0.3",
|
"@tiptap/pm": "^2.1.16",
|
||||||
"@tiptap/react": "^2.0.3",
|
"@tiptap/react": "^2.1.16",
|
||||||
"@tiptap/starter-kit": "^2.0.3",
|
"@tiptap/starter-kit": "^2.1.16",
|
||||||
"@tiptap/suggestion": "2.0.0-beta.91",
|
"@tiptap/suggestion": "^2.1.16",
|
||||||
"@types/react-bootstrap-typeahead": "^5.1.9",
|
"@types/react-bootstrap-typeahead": "^5.1.9",
|
||||||
"axios": "^0.25.0",
|
"axios": "^1.6.7",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.5.1",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.1",
|
||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^4.1.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^3.3.1",
|
||||||
"dompurify": "^3.0.4",
|
"dompurify": "^3.0.8",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fix-date": "^1.1.6",
|
"fix-date": "^1.1.6",
|
||||||
"i18next": "^21.6.13",
|
"i18next": "^23.7.20",
|
||||||
"i18next-browser-languagedetector": "^6.1.3",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"i18next-http-backend": "^1.3.2",
|
"i18next-http-backend": "^2.4.2",
|
||||||
"local-storage": "^2.0.0",
|
"local-storage": "^2.0.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"meyer-reset-scss": "^2.0.4",
|
"meyer-reset-scss": "^2.0.4",
|
||||||
"next": "^13.4.19",
|
"next": "^14.1.0",
|
||||||
"next-i18next": "^10.5.0",
|
"next-i18next": "^15.1.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^1.14.0",
|
"nuqs": "^1.15.4",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-bootstrap-typeahead": "^6.2.3",
|
"react-bootstrap-typeahead": "^6.3.2",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^11.15.5",
|
"react-i18next": "^14.0.1",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
"react-linkify": "^1.0.0-alpha",
|
"react-linkify": "^1.0.0-alpha",
|
||||||
"react-lite-youtube-embed": "^2.3.52",
|
"react-lite-youtube-embed": "^2.4.0",
|
||||||
"react-scroll": "^1.8.5",
|
"react-scroll": "^1.9.0",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.1",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.2",
|
||||||
"remixicon-react": "^1.0.0",
|
"remixicon-react": "^1.0.0",
|
||||||
"resolve-url-loader": "^5.0.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"sanitize-html": "^2.8.1",
|
"sass": "^1.69.7",
|
||||||
"sass": "^1.61.0",
|
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.1",
|
||||||
"valtio": "^1.3.0",
|
"valtio": "^1.13.0",
|
||||||
"youtube-api-v3-wrapper": "^2.3.0"
|
"youtube-api-v3-wrapper": "^2.3.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-essentials": "latest",
|
"@storybook/addon-essentials": "latest",
|
||||||
|
|
@ -90,29 +90,30 @@
|
||||||
"@storybook/nextjs": "latest",
|
"@storybook/nextjs": "latest",
|
||||||
"@storybook/react": "latest",
|
"@storybook/react": "latest",
|
||||||
"@storybook/testing-library": "latest",
|
"@storybook/testing-library": "latest",
|
||||||
"@types/dompurify": "^3.0.2",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/lodash.clonedeep": "^4.5.6",
|
"@types/lodash.clonedeep": "^4.5.9",
|
||||||
"@types/lodash.debounce": "^4.0.6",
|
"@types/lodash.debounce": "^4.0.9",
|
||||||
"@types/node": "17.0.11",
|
"@types/node": "20.11.5",
|
||||||
"@types/pluralize": "^0.0.29",
|
"@types/pluralize": "^0.0.33",
|
||||||
"@types/react": "17.0.38",
|
"@types/react": "18.2.48",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "18.2.18",
|
||||||
"@types/react-infinite-scroller": "^1.2.2",
|
"@types/react-infinite-scroller": "^1.2.5",
|
||||||
"@types/react-linkify": "^1.0.1",
|
"@types/react-linkify": "^1.0.4",
|
||||||
"@types/react-scroll": "^1.8.3",
|
"@types/react-scroll": "^1.8.10",
|
||||||
"@types/sanitize-html": "^2.8.0",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.7",
|
||||||
"eslint": "8.7.0",
|
"eslint": "8.56.0",
|
||||||
"eslint-config-next": "^13.4.19",
|
"eslint-config-next": "14.1.0",
|
||||||
"eslint-plugin-storybook": "^0.6.11",
|
"eslint-plugin-storybook": "^0.6.15",
|
||||||
"eslint-plugin-valtio": "^0.4.1",
|
"eslint-plugin-valtio": "^0.6.2",
|
||||||
"sass-loader": "^13.2.2",
|
"sass-loader": "^13.3.3",
|
||||||
"storybook": "latest",
|
"storybook": "latest",
|
||||||
"typescript": "^4.5.5"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tiptap/extension-mention": {
|
"@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 '~meyer-reset-scss';
|
||||||
@import 'themes.scss';
|
@import 'themes.scss';
|
||||||
|
@import '../app/styles/error.scss';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background-color: var(--background);
|
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": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -15,8 +19,24 @@
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"paths": { "~*": ["./*"] }
|
"paths": {
|
||||||
|
"~*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"plugins": [
|
||||||
"exclude": ["node_modules"]
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue