move routes to (app) route group

This commit is contained in:
Justin Edmund 2025-11-30 22:28:17 -08:00
parent 3498b7d966
commit b78ee7ca20
38 changed files with 4401 additions and 0 deletions

View file

@ -0,0 +1,287 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte'
import Sidebar from '$lib/components/ui/Sidebar.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { Tooltip } from 'bits-ui'
import { beforeNavigate, afterNavigate } from '$app/navigation'
import { browser, dev } from '$app/environment'
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools'
import type { LayoutData } from './$types'
const { data, children } = $props<{
data: LayoutData & { [key: string]: any }
children: () => any
}>()
// Reference to the scrolling container
let mainContent: HTMLElement | undefined
// Store scroll positions for each visited route
const scrollPositions = new Map<string, number>()
// Save scroll position before navigating away and close sidebar
beforeNavigate(({ from }) => {
// Close sidebar when navigating
sidebar.close()
// Save scroll position for the current route
if (from && mainContent) {
const key = from.url.pathname + from.url.search
scrollPositions.set(key, mainContent.scrollTop)
}
})
// Handle scroll restoration or reset after navigation
afterNavigate(({ from, to, type }) => {
if (!mainContent || !to) return
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
if (!mainContent) return
const key = to.url.pathname + to.url.search
// Only restore scroll for browser back/forward navigation
if (type === 'popstate' && scrollPositions.has(key)) {
// User clicked back/forward button - restore their position
mainContent.scrollTop = scrollPositions.get(key) || 0
} else {
// Any other navigation type (link, goto, enter, etc.) - go to top
mainContent.scrollTop = 0
}
})
})
// Optional: Export snapshot for session persistence
export const snapshot = {
capture: () => {
if (!mainContent) return { scroll: 0, positions: [] }
return {
scroll: mainContent.scrollTop,
positions: Array.from(scrollPositions.entries())
}
},
restore: (snapshotData: { scroll?: number; positions?: [string, number][] }) => {
if (!snapshotData || !mainContent) return
// Restore saved positions map
if (snapshotData.positions) {
scrollPositions.clear()
snapshotData.positions.forEach(([key, value]) => {
scrollPositions.set(key, value)
})
}
// Restore current scroll position after DOM is ready
if (browser) {
requestAnimationFrame(() => {
if (mainContent) mainContent.scrollTop = snapshotData.scroll || 0
})
}
}
}
</script>
{#if dev}
<SvelteQueryDevtools />
{/if}
<Tooltip.Provider>
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
<div class="main-pane">
<div class="nav-blur-background"></div>
<div class="main-navigation">
<Navigation
isAuthenticated={data?.isAuthenticated}
account={data?.account}
currentUser={data?.currentUser}
/>
</div>
<main class="main-content" bind:this={mainContent}>
{@render children?.()}
</main>
</div>
<Sidebar
open={sidebar.isOpen}
title={sidebar.title}
onclose={() => sidebar.close()}
scrollable={sidebar.scrollable}
onsave={sidebar.onsave}
saveLabel={sidebar.saveLabel}
element={sidebar.element}
onback={sidebar.onback}
>
{#if sidebar.component}
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
{:else if sidebar.content}
{@render sidebar.content()}
{/if}
</Sidebar>
</div>
</Tooltip.Provider>
<style lang="scss">
@use '$src/themes/effects' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/spacing' as *;
:root {
--sidebar-width: 420px;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
position: relative;
overflow: hidden;
// Main pane with content
.main-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
transition: margin-right $duration-slide ease-in-out;
position: relative;
height: 100%;
// Blur background that shifts with main pane
.nav-blur-background {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 81px; // Matches $nav-height
z-index: 1; // Lower z-index so scrollbar appears above
pointer-events: none;
transition: right $duration-slide ease-in-out;
// Color gradient for the background
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--background) 85%, transparent) 0%,
color-mix(in srgb, var(--background) 60%, transparent) 50%,
color-mix(in srgb, var(--background) 20%, transparent) 85%,
transparent 100%
);
// Single blur value applied to entire element
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
// Mask gradient to fade out the blur effect progressively
mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
}
// Navigation wrapper - fixed but shifts with main-pane
.main-navigation {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10; // Above blur but below scrollbar
transition: right $duration-slide ease-in-out;
pointer-events: auto;
}
// Main content area with independent scroll
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding-top: 81px; // Space for fixed navigation (matches $nav-height)
padding-bottom: 20vh; // Extra space at bottom for comfortable scrolling
z-index: 2; // Ensure scrollbar is above blur background
// Use overlay scrollbars that auto-hide on macOS
overflow-y: overlay;
// Thin, minimal scrollbar styling
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 2px solid transparent;
background-clip: padding-box;
&:hover {
background: rgba(0, 0, 0, 0.4);
background-clip: padding-box;
}
}
// Firefox scrollbar styling
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
}
// When sidebar is open, adjust main pane and navigation
&.sidebar-open {
.main-pane {
margin-right: var(--sidebar-width, 420px);
// Blur background and navigation shift with the main pane
.nav-blur-background,
.main-navigation {
right: var(--sidebar-width, 420px);
}
// Mobile: don't adjust margin, use overlay
@media (max-width: 768px) {
margin-right: 0;
.nav-blur-background,
.main-navigation {
right: 0; // Don't shift on mobile
}
}
}
}
}
// Mobile adjustments
@media (max-width: 768px) {
.app-container {
.main-pane {
.main-content {
// Improve mobile scrolling performance
-webkit-overflow-scrolling: touch;
}
}
// Overlay backdrop when sidebar is open on mobile
&.sidebar-open::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
animation: fadeIn $duration-quick ease-out;
}
}
}
// Fade in animation for mobile backdrop
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View file

@ -0,0 +1,35 @@
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { userAdapter } from '$lib/api/adapters/user.adapter'
import { parseParty } from '$lib/api/schemas/party'
export const load: PageServerLoad = async ({ params, url, depends, locals }) => {
depends('app:profile')
const username = params.username
const pageParam = url.searchParams.get('page')
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
const tab = url.searchParams.get('tab') === 'favorites' ? 'favorites' : 'teams'
const isOwner = locals.session?.account?.username === username
try {
if (tab === 'favorites' && isOwner) {
const fav = await userAdapter.getFavorites({ page })
return {
user: { username } as any,
items: fav.items,
page: fav.page,
total: fav.total,
totalPages: fav.totalPages,
perPage: fav.perPage,
tab,
isOwner
}
}
const { user, items, total, totalPages, perPage } = await userAdapter.getProfile(username, page)
const parties = items.map((p) => parseParty(p))
return { user, items: parties, page, total, totalPages, perPage, tab, isOwner }
} catch (e: any) {
throw error(e?.status || 502, e?.message || 'Failed to load profile')
}
}

View file

@ -0,0 +1,263 @@
<script lang="ts">
import type { PageData } from './$types'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { userQueries } from '$lib/api/queries/user.queries'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
const { data } = $props() as { data: PageData }
const tab = $derived(data.tab || 'teams')
const isOwner = $derived(data.isOwner || false)
const avatarFile = $derived(data.user?.avatar?.picture || '')
const avatarSrc = $derived(getAvatarSrc(avatarFile))
const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
// Note: Type assertion needed because favorites and parties queries have different
// result structures (items vs results) but we handle both in the items $derived
const partiesQuery = createInfiniteQuery(() => {
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
return {
...userQueries.favorites(),
initialData: data.items
? {
pages: [
{
items: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
}
}
return {
...userQueries.parties(data.user?.username ?? ''),
enabled: !!data.user?.username,
initialData: data.items
? {
pages: [
{
results: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
} as unknown as ReturnType<typeof userQueries.favorites>
})
const items = $derived(() => {
if (!partiesQuery.data?.pages) return data.items || []
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
return partiesQuery.data.pages.flatMap((page) => (page as any).items ?? [])
}
return partiesQuery.data.pages.flatMap((page) => (page as any).results ?? [])
})
const isEmpty = $derived(!partiesQuery.isLoading && items().length === 0)
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '300px'
})
$effect(() => {
if (
inViewport.current &&
partiesQuery.hasNextPage &&
!partiesQuery.isFetchingNextPage &&
!partiesQuery.isLoading
) {
partiesQuery.fetchNextPage()
}
})
</script>
<section class="profile">
<header class="header">
{#if data.user?.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${data.user.username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="64"
height="64"
/>
{:else}
<div class="avatar" aria-hidden="true"></div>
{/if}
<div>
<h1>{data.user.username}</h1>
<nav class="tabs" aria-label="Profile sections">
<a class:active={tab === 'teams'} href="?tab=teams" data-sveltekit-preload-data="hover"
>Teams</a
>
{#if isOwner}
<a
class:active={tab === 'favorites'}
href="?tab=favorites"
data-sveltekit-preload-data="hover">Favorites</a
>
{/if}
</nav>
</div>
</header>
{#if partiesQuery.isLoading}
<div class="loading">
<Icon name="loader-2" size={32} />
<p>Loading {tab}...</p>
</div>
{:else if partiesQuery.isError}
<div class="error">
<Icon name="alert-circle" size={32} />
<p>Failed to load {tab}: {partiesQuery.error?.message || 'Unknown error'}</p>
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
</div>
{:else if isEmpty}
<div class="empty">
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
</div>
{:else}
<div class="profile-grid">
<ExploreGrid items={items()} />
{#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
{#if partiesQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{#if !partiesQuery.hasNextPage && items().length > 0}
<div class="end">
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
</div>
{/if}
</div>
{/if}
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.profile {
padding: $unit-2x 0;
}
.header {
display: flex;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-2x;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: $grey-80;
border: 1px solid $grey-75;
object-fit: cover;
}
.sub {
color: $grey-55;
margin: 0;
}
.tabs {
display: flex;
gap: $unit-2x;
margin-top: $unit-half;
}
.tabs a {
text-decoration: none;
color: inherit;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.tabs a.active {
border-color: #3366ff;
color: #3366ff;
}
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.error {
color: var(--text-error, #dc2626);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View file

@ -0,0 +1,9 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages'
export let data: { status: unknown; seo: string }
</script>
<svelte:head>{@html data.seo}</svelte:head>
<h1>{m.hello_world({ name: 'World' })}</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>

View file

@ -0,0 +1,8 @@
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
export const load = async ({ fetch }) => {
const apiBase = PUBLIC_SIERO_API_URL || 'http://localhost:3000'
const response = await fetch(`${apiBase}/api/v1/version`)
const status = await response.json()
return { status }
}

View file

@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { setUserCookie } from '$lib/auth/cookies'
import type { UserCookie } from '$lib/types/UserCookie'
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const userCookie = await request.json() as UserCookie
// Calculate expiry date (60 days from now)
const expires = new Date()
expires.setDate(expires.getDate() + 60)
// Set the user cookie with the updated data
setUserCookie(cookies, userCookie, {
secure: true,
expires
})
return json({ success: true })
} catch (error) {
console.error('Failed to update settings cookie:', error)
return json({ error: 'Failed to update settings' }, { status: 500 })
}
}

View file

@ -0,0 +1,31 @@
<svelte:options runes={true} />
<script lang="ts">
import { page } from '$app/stores'
</script>
<svelte:head>
<title>Collection</title>
</svelte:head>
<div class="container">
<h1>Collection</h1>
<p>Your collection in Granblue Fantasy</p>
<!-- Content will be added here -->
</div>
<style lang="scss">
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
h1 {
margin-bottom: 1rem;
}
p {
color: var(--text-secondary);
}
}
</style>

View file

@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals, url }) => {
// Check authentication first
if (!locals.session.isAuthenticated) {
throw redirect(302, '/auth/login')
}
// Check role authorization
const role = locals.session.account?.role ?? 0
if (role < 7) {
// Redirect to home with no indication of why (security best practice)
throw redirect(302, '/')
}
return {
role,
user: locals.session.user
}
}

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { localizeHref } from '$lib/paraglide/runtime'
import { onMount } from 'svelte'
import { page } from '$app/stores'
import type { PageData } from './$types'
const { data, children }: { data: PageData; children: () => any } = $props()
const baseHref = localizeHref('/database')
const summonsHref = localizeHref('/database/summons')
const charactersHref = localizeHref('/database/characters')
const weaponsHref = localizeHref('/database/weapons')
// Function to check if a nav item is selected based on current path
function isSelected(href: string): boolean {
return $page.url.pathname === href || $page.url.pathname.startsWith(href + '/')
}
// Get user's element for styling
const userElement = $derived((data as any)?.user?.element || 'null')
</script>
{@render children?.()}
<style lang="scss">
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/colors' as colors;
</style>

View file

@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
// Double-check authorization at page level
if (!locals.session.isAuthenticated) {
throw redirect(302, '/auth/login')
}
const role = locals.session.account?.role ?? 0
if (role < 7) {
throw redirect(302, '/')
}
return {}
}

View file

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit'
import type { PageLoad } from './$types'
export const load: PageLoad = async () => {
throw redirect(302, '/database/weapons')
}

View file

@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
// Enforce authorization at individual page level
if (!locals.session.isAuthenticated) {
throw redirect(302, '/auth/login')
}
const role = locals.session.account?.role ?? 0
if (role < 7) {
throw redirect(302, '/')
}
return {}
}

View file

@ -0,0 +1,76 @@
<svelte:options runes={true} />
<script lang="ts">
// Svelte components
import CharacterImageCell from '$lib/components/database/cells/CharacterImageCell.svelte'
import CharacterUncapCell from '$lib/components/database/cells/CharacterUncapCell.svelte'
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
// Utilities
import { getRarityLabel } from '$lib/utils/rarity'
const columns = [
{
id: 'granblueId',
header: 'Image',
width: 80,
cell: CharacterImageCell
},
{
id: 'name',
header: 'Name',
flexgrow: 1,
sort: true,
template: (nameObj: { en: any; ja: any }) => {
if (!nameObj) return '—'
if (typeof nameObj === 'string') return nameObj
// Handle {en: "...", ja: "..."} structure
return nameObj.en || nameObj.ja || '—'
}
},
{
id: 'rarity',
header: 'Rarity',
width: 80,
sort: true,
template: (rarity: number) => getRarityLabel(rarity)
},
{
id: 'element',
header: 'Element',
width: 100,
sort: true,
cell: ElementCell
},
{
id: 'uncap',
header: 'Uncap',
width: 160,
cell: CharacterUncapCell
},
{
id: 'last_updated',
header: 'Last Updated',
width: 120,
sort: true,
cell: LastUpdatedCell
}
]
</script>
<div class="page">
<DatabaseGridWithProvider resource="characters" {columns} pageSize={20} />
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
}
</style>

View file

@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, parent }) => {
try {
// Get parent data to access role
const parentData = await parent()
const character = await entityAdapter.getCharacter(params.id)
if (!character) {
throw error(404, 'Character not found')
}
return {
character,
role: parentData.role
}
} catch (err) {
console.error('Failed to load character:', err)
if (err instanceof Error && 'status' in err && err.status === 404) {
throw error(404, 'Character not found')
}
throw error(500, 'Failed to load character')
}
}

View file

@ -0,0 +1,596 @@
<svelte:options runes={true} />
<script lang="ts">
// SvelteKit imports
import { goto } from '$app/navigation'
// TanStack Query
import { createQuery } from '@tanstack/svelte-query'
import { entityQueries } from '$lib/api/queries/entity.queries'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { withInitialData } from '$lib/query/ssr'
// Utility functions
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
// Components
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { getCharacterImage } from '$lib/utils/images'
// Types
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// Use TanStack Query with SSR initial data
const characterQuery = createQuery(() => ({
...entityQueries.character(data.character?.id ?? ''),
...withInitialData(data.character)
}))
// Get character from query
const character = $derived(characterQuery.data)
const userRole = $derived(data.role || 0)
const canEdit = $derived(userRole >= 7)
// Edit mode state
let editMode = $state(false)
// Query for related characters (same character_id)
const relatedQuery = createQuery(() => ({
queryKey: ['characters', 'related', character?.id],
queryFn: async () => {
if (!character?.id) return []
return entityAdapter.getRelatedCharacters(character.id)
},
enabled: !!character?.characterId && !editMode
}))
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
// Editable fields - create reactive state for each field
let editData = $state({
name: character?.name || '',
granblueId: character?.granblueId || '',
characterId: character?.characterId ?? (null as number | null),
rarity: character?.rarity || 1,
element: character?.element || 0,
race1: character?.race?.[0] ?? null,
race2: character?.race?.[1] ?? null,
gender: character?.gender || 0,
proficiency1: character?.proficiency?.[0] || 0,
proficiency2: character?.proficiency?.[1] || 0,
minHp: character?.hp?.minHp || 0,
maxHp: character?.hp?.maxHp || 0,
maxHpFlb: character?.hp?.maxHpFlb || 0,
minAtk: character?.atk?.minAtk || 0,
maxAtk: character?.atk?.maxAtk || 0,
maxAtkFlb: character?.atk?.maxAtkFlb || 0,
flb: character?.uncap?.flb || false,
ulb: character?.uncap?.ulb || false,
transcendence: character?.uncap?.transcendence || false,
special: character?.special || false
})
// Reset edit data when character changes
$effect(() => {
if (character) {
editData = {
name: character.name || '',
granblueId: character.granblueId || '',
characterId: character.characterId ?? null,
rarity: character.rarity || 1,
element: character.element || 0,
race1: character.race?.[0] ?? null,
race2: character.race?.[1] ?? null,
gender: character.gender || 0,
proficiency1: character.proficiency?.[0] || 0,
proficiency2: character.proficiency?.[1] || 0,
minHp: character.hp?.minHp || 0,
maxHp: character.hp?.maxHp || 0,
maxHpFlb: character.hp?.maxHpFlb || 0,
minAtk: character.atk?.minAtk || 0,
maxAtk: character.atk?.maxAtk || 0,
maxAtkFlb: character.atk?.maxAtkFlb || 0,
flb: character.uncap?.flb || false,
ulb: character.uncap?.ulb || false,
transcendence: character.uncap?.transcendence || false,
special: character.special || false
}
}
})
// Options for select dropdowns - using centralized utilities
const rarityOptions = getRarityOptions()
const elementOptions = getElementOptions()
const raceOptions = getRaceOptions()
const genderOptions = getGenderOptions()
const proficiencyOptions = getProficiencyOptions()
function toggleEditMode() {
editMode = !editMode
saveError = null
saveSuccess = false
// Reset data when canceling
if (!editMode && character) {
editData = {
name: character.name || '',
granblueId: character.granblueId || '',
characterId: character.characterId ?? null,
rarity: character.rarity || 1,
element: character.element || 0,
race1: character.race?.[0] ?? null,
race2: character.race?.[1] ?? null,
gender: character.gender || 0,
proficiency1: character.proficiency?.[0] || 0,
proficiency2: character.proficiency?.[1] || 0,
minHp: character.hp?.minHp || 0,
maxHp: character.hp?.maxHp || 0,
maxHpFlb: character.hp?.maxHpFlb || 0,
minAtk: character.atk?.minAtk || 0,
maxAtk: character.atk?.maxAtk || 0,
maxAtkFlb: character.atk?.maxAtkFlb || 0,
flb: character.uncap?.flb || false,
ulb: character.uncap?.ulb || false,
transcendence: character.uncap?.transcendence || false,
special: character.special || false
}
}
}
async function saveChanges() {
isSaving = true
saveError = null
saveSuccess = false
try {
// Prepare the data for API
const payload = {
name: editData.name,
granblue_id: editData.granblueId,
character_id: editData.characterId,
rarity: editData.rarity,
element: editData.element,
race: [editData.race1, editData.race2].filter((r) => r !== null && r !== undefined),
gender: editData.gender,
proficiency: [editData.proficiency1, editData.proficiency2],
hp: {
min_hp: editData.minHp,
max_hp: editData.maxHp,
max_hp_flb: editData.maxHpFlb
},
atk: {
min_atk: editData.minAtk,
max_atk: editData.maxAtk,
max_atk_flb: editData.maxAtkFlb
},
uncap: {
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence
},
special: editData.special
}
// TODO: When backend endpoint is ready, make the API call here
// const response = await fetch(`/api/v1/characters/${character.id}`, {
// method: 'PUT',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(payload)
// })
// For now, just simulate success
await new Promise((resolve) => setTimeout(resolve, 1000))
saveSuccess = true
editMode = false
// Show success message for 3 seconds
setTimeout(() => {
saveSuccess = false
}, 3000)
} catch (error) {
saveError = 'Failed to save changes. Please try again.'
console.error('Save error:', error)
} finally {
isSaving = false
}
}
// Helper function to get character image
// Helper function for character grid image
function getCharacterGridImage(character: any): string {
return getCharacterImage(character?.granblueId, 'grid', '01')
}
// Calculate uncap properties for the indicator
const uncap = $derived(
editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (character?.uncap ?? { flb: false, ulb: false, transcendence: false })
)
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(
getCharacterMaxUncapLevel({ special, uncap: { flb, ulb, transcendence } })
)
const transcendenceStage = $derived(transcendence ? 5 : 0)
// Get element name for checkbox theming
const elementName = $derived.by(() => {
const el = editMode ? editData.element : character?.element
const label = getElementLabel(el)
return label !== '—' && label !== 'Null'
? (label.toLowerCase() as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light')
: undefined
})
</script>
<div>
{#if character}
<div class="content">
<DetailsHeader
type="character"
item={character}
image={getCharacterGridImage(character)}
onEdit={toggleEditMode}
showEdit={canEdit}
{editMode}
onSave={saveChanges}
onCancel={toggleEditMode}
{isSaving}
/>
{#if saveSuccess || saveError}
<div class="edit-controls">
{#if saveSuccess}
<span class="success-message">Changes saved successfully!</span>
{/if}
{#if saveError}
<span class="error-message">{saveError}</span>
{/if}
</div>
{/if}
<section class="details">
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem
label="Rarity"
bind:value={editData.rarity}
editable={true}
type="select"
options={rarityOptions}
/>
<DetailItem
label="Granblue ID"
bind:value={editData.granblueId}
editable={true}
type="text"
/>
<DetailItem
label="Character ID"
bind:value={editData.characterId}
editable={true}
type="number"
/>
{:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblueId} />
{/if}
</DetailsContainer>
<DetailsContainer title="Details">
{#if character.uncap}
<DetailItem label="Uncap">
<UncapIndicator
type="character"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
{special}
editable={false}
/>
</DetailItem>
{/if}
{#if editMode}
<DetailItem
label="FLB"
bind:value={editData.flb}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="ULB"
bind:value={editData.ulb}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="Transcendence"
bind:value={editData.transcendence}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="Special"
bind:value={editData.special}
editable={true}
type="checkbox"
element={elementName}
/>
{/if}
{#if editMode}
<DetailItem
label="Element"
bind:value={editData.element}
editable={true}
type="select"
options={elementOptions}
/>
<DetailItem
label="Race 1"
bind:value={editData.race1}
editable={true}
type="select"
options={raceOptions}
/>
<DetailItem
label="Race 2"
bind:value={editData.race2}
editable={true}
type="select"
options={raceOptions}
/>
<DetailItem
label="Gender"
bind:value={editData.gender}
editable={true}
type="select"
options={genderOptions}
/>
<DetailItem
label="Proficiency 1"
bind:value={editData.proficiency1}
editable={true}
type="select"
options={proficiencyOptions}
/>
<DetailItem
label="Proficiency 2"
bind:value={editData.proficiency2}
editable={true}
type="select"
options={proficiencyOptions}
/>
{:else}
<DetailItem label="Element" value={getElementLabel(character.element)} />
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} />
{#if character.race?.[1]}
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
{/if}
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
<DetailItem
label="Proficiency 1"
value={getProficiencyLabel(character.proficiency?.[0] ?? 0)}
/>
<DetailItem
label="Proficiency 2"
value={getProficiencyLabel(character.proficiency?.[1] ?? 0)}
/>
{/if}
</DetailsContainer>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem
label="Base HP"
bind:value={editData.minHp}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max HP"
bind:value={editData.maxHp}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max HP (FLB)"
bind:value={editData.maxHpFlb}
editable={true}
type="number"
placeholder="0"
/>
{:else}
<DetailItem label="Base HP" value={character.hp?.minHp} />
<DetailItem label="Max HP" value={character.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem
label="Base Attack"
bind:value={editData.minAtk}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max Attack"
bind:value={editData.maxAtk}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max Attack (FLB)"
bind:value={editData.maxAtkFlb}
editable={true}
type="number"
placeholder="0"
/>
{:else}
<DetailItem label="Base Attack" value={character.atk?.minAtk} />
<DetailItem label="Max Attack" value={character.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} />
{/if}
{/if}
</DetailsContainer>
{#if !editMode && relatedQuery.data?.length}
<DetailsContainer title="Related Units">
<div class="related-units">
{#each relatedQuery.data as related}
<a href="/database/characters/{related.id}" class="related-unit">
<img
src={getCharacterImage(related.granblueId, 'grid', '01')}
alt={related.name.en}
class="related-image"
/>
<span class="related-name">{related.name.en}</span>
</a>
{/each}
</div>
</DetailsContainer>
{/if}
</section>
</div>
{:else}
<div class="not-found">
<h2>Character Not Found</h2>
<p>The character you're looking for could not be found.</p>
<button onclick={() => goto('/database/characters')}>Back to Characters</button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/effects' as effects;
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: #007bff;
color: white;
border: none;
padding: spacing.$unit * 0.5 spacing.$unit;
border-radius: 4px;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
background: #0056b3;
}
}
}
.content {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: visible;
position: relative;
.details {
display: flex;
flex-direction: column;
}
}
.edit-controls {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-80;
display: flex;
gap: spacing.$unit;
align-items: center;
.success-message {
color: colors.$grey-30;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.related-units {
display: flex;
flex-wrap: wrap;
gap: spacing.$unit-2x;
padding: spacing.$unit-2x;
}
.related-unit {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: colors.$grey-30;
&:hover .related-image {
transform: scale(1.05);
}
}
.related-image {
width: 128px;
height: auto;
border-radius: layout.$item-corner;
transition: transform 0.2s ease;
}
.related-name {
margin-top: spacing.$unit;
font-size: typography.$font-small;
text-align: center;
}
</style>

View file

@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
// Enforce authorization at individual page level
if (!locals.session.isAuthenticated) {
throw redirect(302, '/auth/login')
}
const role = locals.session.account?.role ?? 0
if (role < 7) {
throw redirect(302, '/')
}
return {}
}

View file

@ -0,0 +1,91 @@
<svelte:options runes={true} />
<script lang="ts">
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
import type { IColumn } from 'wx-svelte-grid'
import SummonImageCell from '$lib/components/database/cells/SummonImageCell.svelte'
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
import SummonUncapCell from '$lib/components/database/cells/SummonUncapCell.svelte'
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
import { getRarityLabel } from '$lib/utils/rarity'
// Column configuration for summons
const columns: IColumn[] = [
{
id: 'granblueId',
header: 'Image',
width: 80,
cell: SummonImageCell
},
{
id: 'name',
header: 'Name',
flexgrow: 1,
sort: true,
template: (nameObj: any) => {
// nameObj is the name property itself, not the full item
if (!nameObj) return '—'
if (typeof nameObj === 'string') return nameObj
// Handle {en: "...", ja: "..."} structure
return nameObj.en || nameObj.ja || '—'
}
},
{
id: 'rarity',
header: 'Rarity',
width: 80,
sort: true,
template: (rarity: any) => getRarityLabel(rarity)
},
{
id: 'element',
header: 'Element',
width: 100,
sort: true,
cell: ElementCell
},
{
id: 'uncap',
header: 'Uncap',
width: 160,
cell: SummonUncapCell
},
{
id: 'last_updated',
header: 'Last Updated',
width: 120,
sort: true,
cell: LastUpdatedCell
}
]
</script>
<div class="database-page">
<DatabaseGridWithProvider resource="summons" {columns} pageSize={20} />
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.database-page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
}
.page-header {
margin-bottom: spacing.$unit-2x;
h1 {
font-size: typography.$font-xxlarge;
font-weight: typography.$bold;
margin-bottom: spacing.$unit-half;
}
.subtitle {
font-size: typography.$font-regular;
color: colors.$grey-50;
}
}
</style>

View file

@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, parent }) => {
try {
// Get parent data to access role
const parentData = await parent()
const summon = await entityAdapter.getSummon(params.id)
if (!summon) {
throw error(404, 'Summon not found')
}
return {
summon,
role: parentData.role
}
} catch (err) {
console.error('Failed to load summon:', err)
if (err instanceof Error && 'status' in err && err.status === 404) {
throw error(404, 'Summon not found')
}
throw error(500, 'Failed to load summon')
}
}

View file

@ -0,0 +1,267 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
// TanStack Query
import { createQuery } from '@tanstack/svelte-query'
import { entityQueries } from '$lib/api/queries/entity.queries'
import { withInitialData } from '$lib/query/ssr'
// Utilities
import { getRarityLabel } from '$lib/utils/rarity'
import { getElementLabel, getElementIcon } from '$lib/utils/element'
import { getSummonImage } from '$lib/utils/images'
// Components
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// Use TanStack Query with SSR initial data
const summonQuery = createQuery(() => ({
...entityQueries.summon(data.summon?.id ?? ''),
...withInitialData(data.summon)
}))
// Get summon from query
const summon = $derived(summonQuery.data)
// Helper function to get summon grid image
function getSummonGridImage(summon: any): string {
return getSummonImage(summon?.granblueId, 'grid')
}
// Calculate uncap properties for the indicator
const uncap = $derived(summon?.uncap ?? {})
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
// Calculate maximum uncap level based on available uncaps
// Summons: 3 base + FLB + ULB + transcendence
const getMaxUncapLevel = () => {
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
}
const uncapLevel = $derived(getMaxUncapLevel())
// For details view, show maximum transcendence stage when available
const transcendenceStage = $derived(transcendence ? 5 : 0)
</script>
<div class="summon-detail">
{#if summon}
<div class="summon-content">
<DetailsHeader type="summon" item={summon} image={getSummonGridImage(summon)} />
<DetailsContainer title="HP Stats">
<DetailItem label="Base HP" value={summon.hp?.minHp} />
<DetailItem label="Max HP" value={summon.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={summon.hp?.maxHpFlb} />
{/if}
{#if ulb}
<DetailItem label="Max HP (ULB)" value={summon.hp?.maxHpUlb} />
{/if}
{#if transcendence}
<DetailItem label="Max HP (XLB)" value={summon.hp?.maxHpXlb} />
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
<DetailItem label="Base Attack" value={summon.atk?.minAtk} />
<DetailItem label="Max Attack" value={summon.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={summon.atk?.maxAtkFlb} />
{/if}
{#if ulb}
<DetailItem label="Max Attack (ULB)" value={summon.atk?.maxAtkUlb} />
{/if}
{#if transcendence}
<DetailItem label="Max Attack (XLB)" value={summon.atk?.maxAtkXlb} />
{/if}
</DetailsContainer>
<DetailsContainer title="Details">
<DetailItem label="Series" value={summon.series} />
{#if summon.uncap}
<DetailItem label="Uncap">
<UncapIndicator
type="summon"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
editable={false}
/>
</DetailItem>
{/if}
</DetailsContainer>
<div class="summon-abilities">
<h3>Call Effect</h3>
<div class="abilities-section">
{#if summon.callName || summon.callDescription}
<div class="ability-item">
<h4 class="ability-name">{summon.callName || 'Call Effect'}</h4>
<p class="ability-description">
{summon.callDescription || 'No description available'}
</p>
</div>
{:else}
<p class="no-abilities">No call effect information available</p>
{/if}
</div>
<h3>Aura Effect</h3>
<div class="abilities-section">
{#if summon.auraName || summon.auraDescription}
<div class="ability-item">
<h4 class="ability-name">{summon.auraName || 'Aura Effect'}</h4>
<p class="ability-description">
{summon.auraDescription || 'No description available'}
</p>
</div>
{:else}
<p class="no-abilities">No aura effect information available</p>
{/if}
</div>
{#if summon.subAuraName || summon.subAuraDescription}
<h3>Sub Aura Effect</h3>
<div class="abilities-section">
<div class="ability-item">
<h4 class="ability-name">{summon.subAuraName || 'Sub Aura Effect'}</h4>
<p class="ability-description">
{summon.subAuraDescription || 'No description available'}
</p>
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="not-found">
<h2>Summon Not Found</h2>
<p>The summon you're looking for could not be found.</p>
<button onclick={() => goto('/database/summons')}>Back to Summons</button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.summon-detail {
padding: spacing.$unit-2x 0;
}
.page-header {
margin-bottom: spacing.$unit-2x;
.back-button {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: spacing.$unit-half spacing.$unit;
border-radius: 4px;
cursor: pointer;
font-size: typography.$font-small;
margin-bottom: spacing.$unit;
transition: all 0.2s;
&:hover {
background: #e9ecef;
}
}
h1 {
font-size: typography.$font-xxlarge;
font-weight: typography.$bold;
margin: 0;
}
}
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: #007bff;
color: white;
border: none;
padding: spacing.$unit-half spacing.$unit;
border-radius: 4px;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
background: #0056b3;
}
}
}
.summon-content {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.summon-abilities {
padding: spacing.$unit-2x;
border-bottom: 1px solid #e5e5e5;
&:last-child {
border-bottom: none;
}
h3 {
font-size: typography.$font-large;
font-weight: typography.$bold;
margin: 0 0 spacing.$unit 0;
}
.abilities-section {
margin-bottom: spacing.$unit * 2;
&:last-child {
margin-bottom: 0;
}
.ability-item {
padding: spacing.$unit;
background: #f8f9fa;
border-radius: 4px;
.ability-name {
font-size: typography.$font-medium;
font-weight: typography.$medium;
margin: 0 0 spacing.$unit * 0.5 0;
color: #333;
}
.ability-description {
font-size: typography.$font-small;
color: #666;
margin: 0;
line-height: 1.4;
}
}
.no-abilities {
text-align: center;
color: #666;
font-style: italic;
padding: spacing.$unit;
}
}
}
</style>

View file

@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
// Enforce authorization at individual page level
if (!locals.session.isAuthenticated) {
throw redirect(302, '/auth/login')
}
const role = locals.session.account?.role ?? 0
if (role < 7) {
throw redirect(302, '/')
}
return {}
}

View file

@ -0,0 +1,99 @@
<svelte:options runes={true} />
<script lang="ts">
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
import type { IColumn } from 'wx-svelte-grid'
import WeaponImageCell from '$lib/components/database/cells/WeaponImageCell.svelte'
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
import ProficiencyCell from '$lib/components/database/cells/ProficiencyCell.svelte'
import WeaponUncapCell from '$lib/components/database/cells/WeaponUncapCell.svelte'
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
import { getRarityLabel } from '$lib/utils/rarity'
// Column configuration for weapons
const columns: IColumn[] = [
{
id: 'granblueId',
header: 'Image',
width: 80,
cell: WeaponImageCell
},
{
id: 'name',
header: 'Name',
flexgrow: 1,
sort: true,
template: (nameObj: any) => {
// nameObj is the name property itself, not the full item
if (!nameObj) return '—'
if (typeof nameObj === 'string') return nameObj
// Handle {en: "...", ja: "..."} structure
return nameObj.en || nameObj.ja || '—'
}
},
{
id: 'rarity',
header: 'Rarity',
width: 80,
sort: true,
template: (rarity: any) => getRarityLabel(rarity)
},
{
id: 'element',
header: 'Element',
width: 100,
sort: true,
cell: ElementCell
},
{
id: 'proficiency',
header: 'Proficiency',
width: 100,
sort: true,
cell: ProficiencyCell
},
{
id: 'uncap',
header: 'Uncap',
width: 160,
cell: WeaponUncapCell
},
{
id: 'last_updated',
header: 'Last Updated',
width: 120,
sort: true,
cell: LastUpdatedCell
}
]
</script>
<div class="database-page">
<DatabaseGridWithProvider resource="weapons" {columns} pageSize={20} />
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.database-page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
}
.page-header {
margin-bottom: spacing.$unit-2x;
h1 {
font-size: typography.$font-xxlarge;
font-weight: typography.$bold;
margin-bottom: spacing.$unit-half;
}
.subtitle {
font-size: typography.$font-regular;
color: colors.$grey-50;
}
}
</style>

View file

@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, parent }) => {
try {
// Get parent data to access role
const parentData = await parent()
const weapon = await entityAdapter.getWeapon(params.id)
if (!weapon) {
throw error(404, 'Weapon not found')
}
return {
weapon,
role: parentData.role
}
} catch (err) {
console.error('Failed to load weapon:', err)
if (err instanceof Error && 'status' in err && err.status === 404) {
throw error(404, 'Weapon not found')
}
throw error(500, 'Failed to load weapon')
}
}

View file

@ -0,0 +1,241 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
// TanStack Query
import { createQuery } from '@tanstack/svelte-query'
import { entityQueries } from '$lib/api/queries/entity.queries'
import { withInitialData } from '$lib/query/ssr'
// Utilities
import { getRarityLabel } from '$lib/utils/rarity'
import { getElementLabel, getElementIcon } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyIcon } from '$lib/utils/proficiency'
import { getWeaponGridImage } from '$lib/utils/images'
// Components
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// Use TanStack Query with SSR initial data
const weaponQuery = createQuery(() => ({
...entityQueries.weapon(data.weapon?.id ?? ''),
...withInitialData(data.weapon)
}))
// Get weapon from query
const weapon = $derived(weaponQuery.data)
// Helper function to get weapon grid image
function getWeaponImage(weapon: any): string {
return getWeaponGridImage(weapon?.granblueId, weapon?.element, weapon?.instanceElement)
}
// Calculate uncap properties for the indicator
const uncap = $derived(weapon?.uncap ?? {})
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
// Calculate maximum uncap level based on available uncaps
// Weapons: 3 base + FLB + ULB + transcendence
const getMaxUncapLevel = () => {
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
}
const uncapLevel = $derived(getMaxUncapLevel())
// For details view, show maximum transcendence stage when available
const transcendenceStage = $derived(transcendence ? 5 : 0)
</script>
<div class="weapon-detail">
{#if weapon}
<div class="weapon-content">
<DetailsHeader type="weapon" item={weapon} image={getWeaponImage(weapon)} />
<DetailsContainer title="Level & Skill">
<DetailItem label="Max Level" value={weapon.maxLevel} />
<DetailItem label="Max Skill Level" value={weapon.skillLevelCap} />
{#if weapon.uncap}
<DetailItem label="Uncap">
<UncapIndicator
type="weapon"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
editable={false}
/>
</DetailItem>
{/if}
</DetailsContainer>
<DetailsContainer title="HP Stats">
<DetailItem label="Base HP" value={weapon.hp?.minHp} />
<DetailItem label="Max HP" value={weapon.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={weapon.hp?.maxHpFlb} />
{/if}
{#if ulb}
<DetailItem label="Max HP (ULB)" value={weapon.hp?.maxHpUlb} />
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
<DetailItem label="Base Attack" value={weapon.atk?.minAtk} />
<DetailItem label="Max Attack" value={weapon.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={weapon.atk?.maxAtkFlb} />
{/if}
{#if ulb}
<DetailItem label="Max Attack (ULB)" value={weapon.atk?.maxAtkUlb} />
{/if}
</DetailsContainer>
<div class="weapon-skills">
<h3>Skills</h3>
<div class="skills-grid">
{#if weapon.weapon_skills && weapon.weapon_skills.length > 0}
{#each weapon.weapon_skills as skill}
<div class="skill-item">
<h4 class="skill-name">{skill.name || 'Unknown Skill'}</h4>
<p class="skill-description">{skill.description || 'No description available'}</p>
</div>
{/each}
{:else}
<p class="no-skills">No skills available</p>
{/if}
</div>
</div>
</div>
{:else}
<div class="not-found">
<h2>Weapon Not Found</h2>
<p>The weapon you're looking for could not be found.</p>
<button onclick={() => goto('/database/weapons')}>Back to Weapons</button>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.weapon-detail {
padding: spacing.$unit-2x 0;
}
.page-header {
margin-bottom: spacing.$unit-2x;
.back-button {
background: #f8f9fa;
border: 1px solid #dee2e6;
padding: spacing.$unit * 0.5 spacing.$unit;
border-radius: 4px;
cursor: pointer;
font-size: typography.$font-small;
margin-bottom: spacing.$unit;
transition: all 0.2s;
&:hover {
background: #e9ecef;
}
}
h1 {
font-size: typography.$font-xxlarge;
font-weight: typography.$bold;
margin: 0;
}
}
.not-found {
text-align: center;
padding: spacing.$unit * 4;
button {
background: #007bff;
color: white;
border: none;
padding: spacing.$unit * 0.5 spacing.$unit;
border-radius: 4px;
cursor: pointer;
margin-top: spacing.$unit;
&:hover {
background: #0056b3;
}
}
}
.weapon-content {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.weapon-skills {
padding: spacing.$unit * 2;
border-bottom: 1px solid #e5e5e5;
&:last-child {
border-bottom: none;
}
h3 {
font-size: typography.$font-large;
font-weight: typography.$bold;
margin: 0 0 spacing.$unit 0;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: spacing.$unit;
.skill-item {
padding: spacing.$unit;
background: #f8f9fa;
border-radius: 4px;
.skill-name {
font-size: typography.$font-medium;
font-weight: typography.$medium;
margin: 0 0 spacing.$unit * 0.5 0;
color: #333;
}
.skill-description {
font-size: typography.$font-small;
color: #666;
margin: 0;
line-height: 1.4;
}
}
.no-skills {
grid-column: 1 / -1;
text-align: center;
color: #666;
font-style: italic;
}
}
}
@media (max-width: 768px) {
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,31 @@
<svelte:options runes={true} />
<script lang="ts">
import { page } from '$app/stores'
</script>
<svelte:head>
<title>Guides</title>
</svelte:head>
<div class="container">
<h1>Guides</h1>
<p>Guides and resources for Granblue Fantasy players.</p>
<!-- Content will be added here -->
</div>
<style lang="scss">
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
h1 {
margin-bottom: 1rem;
}
p {
color: var(--text-secondary);
}
}
</style>

View file

@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ locals }) => {
const username = locals.session?.account?.username
if (!username) throw redirect(302, '/auth/login')
throw redirect(302, `/${encodeURIComponent(username)}`)
}

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { AccountCookie } from '$lib/types/AccountCookie'
import type { UserCookie } from '$lib/types/UserCookie'
export let data: { account: AccountCookie; user: UserCookie }
</script>
<h1>Welcome, {data.account.username}!</h1>
<section>
<h2>Account</h2>
<ul>
<li><strong>User ID:</strong> {data.account.userId}</li>
<li><strong>Role:</strong> {data.account.role}</li>
</ul>
<section>
<h2>Preferences</h2>
<ul>
<li><strong>Language:</strong> {data.user.language}</li>
<li><strong>Theme:</strong> {data.user.theme}</li>
<li><strong>Gender:</strong> {data.user.gender}</li>
<li><strong>Element:</strong> {data.user.element}</li>
<li><strong>Picture:</strong> {data.user.picture}</li>
</ul>
</section>
<form method="post" action="/auth/logout">
<button>Log out</button>
</form>
</section>

View file

@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { getAccountFromCookies, getUserFromCookies } from '$lib/auth/cookies'
export const load: PageServerLoad = async ({ cookies, url }) => {
const account = getAccountFromCookies(cookies)
const currentUser = getUserFromCookies(cookies)
// Redirect to login if not authenticated
if (!account || !currentUser) {
throw redirect(303, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`)
}
return {
account,
currentUser
}
}

View file

@ -0,0 +1,366 @@
<svelte:options runes={true} />
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import Select from '$lib/components/ui/Select.svelte'
import Switch from '$lib/components/ui/switch/Switch.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { pictureData } from '$lib/utils/pictureData'
import { users } from '$lib/api/resources/users'
import type { UserCookie } from '$lib/types/UserCookie'
import { invalidateAll } from '$app/navigation'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// Check authentication
if (!data.account || !data.currentUser) {
goto('/auth/login')
}
const account = data.account!
const user = data.currentUser!
// Form state
let picture = $state(user.picture)
let gender = $state(user.gender)
let language = $state(user.language)
let theme = $state(user.theme)
let bahamut = $state(user.bahamut ?? false)
let saving = $state(false)
let error = $state<string | null>(null)
let success = $state(false)
// Get current locale from user settings
const locale = $derived(user.language as 'en' | 'ja')
// Prepare options for selects
const pictureOptions = $derived(
pictureData
.sort((a, b) => a.name.en.localeCompare(b.name.en))
.map((p) => ({
value: p.filename,
label: p.name[locale] || p.name.en,
image: `/profile/${p.filename}.png`
}))
)
const genderOptions = [
{ value: 0, label: 'Gran' },
{ value: 1, label: 'Djeeta' }
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' }
]
const themeOptions = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]
// Get current picture data
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
// Handle form submission
async function handleSave(e: Event) {
e.preventDefault()
error = null
success = false
saving = true
try {
// Prepare the update data
const updateData = {
picture,
element: currentPicture?.element,
gender,
language,
theme
}
// Call API to update user settings
const response = await users.update(account.userId, updateData)
// Update the user cookie
const updatedUser: UserCookie = {
picture: response.avatar.picture,
element: response.avatar.element,
language: response.language,
gender: response.gender,
theme: response.theme,
bahamut
}
// Make a request to update the cookie server-side
await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedUser)
})
success = true
// If language or theme changed, we need to reload
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) {
setTimeout(() => {
invalidateAll()
window.location.reload()
}, 1000)
}
} catch (err) {
console.error('Failed to update settings:', err)
error = 'Failed to update settings. Please try again.'
} finally {
saving = false
}
}
</script>
<div class="settings-page">
<div class="settings-container">
<h1>Account Settings</h1>
<p class="username">@{account.username}</p>
<form onsubmit={handleSave} class="settings-form">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if success}
<div class="success-message">Settings saved successfully!</div>
{/if}
<div class="form-fields">
<!-- Picture Selection with Preview -->
<div class="picture-section">
<label>Avatar</label>
<div class="picture-content">
<div class="current-avatar">
<img
src={`/profile/${picture}.png`}
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{currentPicture?.element}"
/>
</div>
<Select
bind:value={picture}
options={pictureOptions}
placeholder="Select an avatar"
fullWidth
/>
</div>
</div>
<!-- Gender Selection -->
<div class="form-field">
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
/>
</div>
<!-- Language Selection -->
<div class="form-field">
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
/>
</div>
<!-- Theme Selection -->
<div class="form-field">
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
/>
</div>
<!-- Admin Mode (only for admins) -->
{#if account.role === 9}
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
</label>
</div>
{/if}
</div>
<div class="form-actions">
<Button
variant="ghost"
href="/me"
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/colors' as colors;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
@use '$src/themes/effects' as effects;
.settings-page {
padding: spacing.$unit-3x;
max-width: 600px;
margin: 0 auto;
}
.settings-container {
background-color: var(--card-bg);
border: effects.$page-border;
border-radius: layout.$page-corner;
padding: spacing.$unit-4x;
box-shadow: effects.$page-elevation;
h1 {
font-size: typography.$font-xlarge;
color: var(--text-primary);
margin-bottom: spacing.$unit-half;
}
.username {
color: var(--text-secondary);
font-size: typography.$font-regular;
margin-bottom: spacing.$unit-4x;
}
}
.settings-form {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.error-message {
background-color: rgba(colors.$error, 0.1);
border: 1px solid colors.$error;
border-radius: layout.$card-corner;
color: colors.$error;
padding: spacing.$unit-2x;
}
.success-message {
background-color: rgba(colors.$yellow, 0.1);
border: 1px solid colors.$yellow;
border-radius: layout.$card-corner;
color: colors.$yellow;
padding: spacing.$unit-2x;
}
.form-fields {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.form-field {
display: flex;
flex-direction: column;
}
.picture-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
label {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
}
.picture-content {
display: flex;
gap: spacing.$unit-3x;
align-items: center;
.current-avatar {
flex-shrink: 0;
width: 100px;
height: 100px;
.avatar-preview {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: layout.$full-corner;
padding: spacing.$unit;
background-color: var(--placeholder-bg);
&.element-fire {
background-color: colors.$fire-bg-20;
}
&.element-water {
background-color: colors.$water-bg-20;
}
&.element-earth {
background-color: colors.$earth-bg-20;
}
&.element-wind {
background-color: colors.$wind-bg-20;
}
&.element-light {
background-color: colors.$light-bg-20;
}
&.element-dark {
background-color: colors.$dark-bg-20;
}
}
}
}
}
.switch-field {
label {
display: flex;
align-items: center;
justify-content: space-between;
padding: spacing.$unit-2x;
background-color: var(--input-bg);
border-radius: layout.$card-corner;
span {
font-size: typography.$font-regular;
color: var(--text-primary);
}
}
}
.form-actions {
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
padding-top: spacing.$unit-2x;
border-top: 1px solid var(--border-color);
}
</style>

View file

@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
export const load: PageServerLoad = async ({ params, locals }) => {
const authUserId = locals.session?.account?.userId
let partyFound = false
let party = null
let canEdit = false
try {
// Fetch the party using adapter
party = await partyAdapter.getByShortcode(params.id)
partyFound = true
// Determine if user can edit
canEdit = authUserId ? party.user?.id === authUserId : false
} catch (err) {
// Error is expected for test/invalid IDs
}
return {
party: party ? structuredClone(party) : null,
canEdit: Boolean(canEdit),
partyFound,
authUserId: authUserId || null
}
}

View file

@ -0,0 +1,42 @@
<script lang="ts">
import type { PageData } from './$types'
import Party from '$lib/components/party/Party.svelte'
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import { withInitialData } from '$lib/query/ssr'
let { data }: { data: PageData } = $props()
/**
* TanStack Query v6 SSR Integration Example
*
* This demonstrates the `withInitialData` pattern for pages using +page.server.ts.
* The server-fetched party data is used as initial data for the query, which means:
*
* 1. No loading state on initial render (data is already available)
* 2. The query can refetch in the background when data becomes stale
* 3. The data is cached and shared across components using the same query key
*
* Note: The Party component currently manages its own state, so we pass the
* server data directly. In a future refactor, the Party component could use
* this query directly for automatic cache updates and background refetching.
*/
const partyQuery = createQuery(() => ({
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
...withInitialData(data.party),
enabled: !!data.party?.shortcode
}))
// Use query data if available, fall back to server data
// This allows the query to refetch and update the UI automatically
const party = $derived(partyQuery.data ?? data.party)
</script>
{#if party}
<Party party={party} canEdit={data.canEdit || false} authUserId={data.authUserId} />
{:else}
<div>
<h1>Party not found</h1>
<p>No party data available for this code.</p>
</div>
{/if}

View file

@ -0,0 +1,34 @@
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
export const load: PageServerLoad = async ({ url, depends }) => {
depends('app:parties:list')
const pageParam = url.searchParams.get('page')
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
try {
const response = await partyAdapter.list({ page })
return {
items: response.results,
page,
total: response.total,
totalPages: response.totalPages,
perPage: response.perPage || 20
}
} catch (e: any) {
console.error('[explore/+page.server.ts] Failed to load teams:', {
error: e,
message: e?.message,
status: e?.status,
stack: e?.stack,
details: e?.details
})
const errorMessage = `Failed to load teams: ${e?.message || 'Unknown error'}. Status: ${e?.status || 'unknown'}`
throw error(e?.status || 502, errorMessage)
}
}

View file

@ -0,0 +1,168 @@
<script lang="ts">
import type { PageData } from './$types'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { partyQueries } from '$lib/api/queries/party.queries'
import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
const { data } = $props() as { data: PageData }
const partiesQuery = createInfiniteQuery(() => ({
...partyQueries.list(),
initialData: data.items
? {
pages: [
{
results: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
}))
const items = $derived(
partiesQuery.data?.pages.flatMap((page) => page.results) ?? data.items ?? []
)
const isEmpty = $derived(!partiesQuery.isLoading && items.length === 0)
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '300px'
})
$effect(() => {
if (
inViewport.current &&
partiesQuery.hasNextPage &&
!partiesQuery.isFetchingNextPage &&
!partiesQuery.isLoading
) {
partiesQuery.fetchNextPage()
}
})
</script>
<section class="explore">
<header>
<h1>Explore Teams</h1>
</header>
{#if partiesQuery.isLoading}
<div class="loading">
<Icon name="loader-2" size={32} />
<p>Loading teams...</p>
</div>
{:else if partiesQuery.isError}
<div class="error">
<Icon name="alert-circle" size={32} />
<p>Failed to load teams: {partiesQuery.error?.message || 'Unknown error'}</p>
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
</div>
{:else if isEmpty}
<div class="empty">
<p>No teams found</p>
</div>
{:else}
<div class="explore-grid">
<ExploreGrid items={items} />
{#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
{#if partiesQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{#if !partiesQuery.hasNextPage && items.length > 0}
<div class="end">
<p>You've reached the end of all teams!</p>
</div>
{/if}
</div>
{/if}
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.explore {
padding: $unit-2x 0;
}
h1 {
margin: 0 0 $unit-2x 0;
}
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.error {
color: var(--text-error, #dc2626);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View file

@ -0,0 +1,820 @@
<svelte:options runes={true} />
<script lang="ts">
import type { PageData } from './$types'
import WeaponGrid from '$lib/components/grids/WeaponGrid.svelte'
import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
import JobSection from '$lib/components/job/JobSection.svelte'
import { openSearchSidebar, closeSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
import { GridType } from '$lib/types/enums'
import { Gender } from '$lib/utils/jobUtils'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { transformSkillsToArray } from '$lib/utils/jobSkills'
import { setContext } from 'svelte'
import type { SearchResult } from '$lib/api/adapters'
import { gridAdapter } from '$lib/api/adapters'
import { getLocalId } from '$lib/utils/localId'
import { storeEditKey } from '$lib/utils/editKeys'
import type { Party } from '$lib/types/api/party'
// TanStack Query
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import { partyKeys } from '$lib/api/queries/party.queries'
// TanStack Query mutations
import { useCreateParty } from '$lib/api/mutations/party.mutations'
import {
useCreateGridWeapon,
useCreateGridCharacter,
useCreateGridSummon,
useDeleteGridWeapon,
useDeleteGridCharacter,
useDeleteGridSummon
} from '$lib/api/mutations/grid.mutations'
import { Dialog } from 'bits-ui'
import { replaceState } from '$app/navigation'
// Props
interface Props {
data: PageData
}
let { data }: Props = $props()
// Get authentication status from data prop (no store subscription!)
let isAuthenticated = $derived(data.isAuthenticated)
let currentUser = $derived(data.currentUser)
// Local, client-only state for tab selection (Svelte 5 runes)
let activeTab = $state<GridType>(GridType.Weapon)
// Open search sidebar on mount
let hasOpenedSidebar = $state(false)
$effect(() => {
if (!hasOpenedSidebar) {
hasOpenedSidebar = true
// Set initial selected slot to mainhand weapon
selectedSlot = -1
// Small delay to let the page render first
setTimeout(() => {
openSearchSidebar({
type: 'weapon',
onAddItems: handleAddItems,
canAddMore: true
})
}, 100)
}
})
function selectTab(gridType: GridType) {
activeTab = gridType
// Set selectedSlot to first valid empty slot for this tab
if (gridType === GridType.Character) {
// Find first empty character slot
const emptySlot = [0, 1, 2, 3, 4].find(i => !characters.find(c => c.position === i))
selectedSlot = emptySlot ?? 0
} else if (gridType === GridType.Weapon) {
// Find first empty weapon slot (mainhand first, then grid)
const emptySlot = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8].find(i =>
!weapons.find(w => w.position === i || (i === -1 && w.mainhand))
)
selectedSlot = emptySlot ?? -1
} else {
// Find first empty summon slot (main, grid, friend)
const emptySlot = [-1, 0, 1, 2, 3, 6].find(i =>
!summons.find(s => s.position === i || (i === -1 && s.main) || (i === 6 && s.friend))
)
selectedSlot = emptySlot ?? -1
}
// Open sidebar when switching tabs
openSearchSidebar({
type: gridType === GridType.Weapon ? 'weapon' :
gridType === GridType.Summon ? 'summon' :
'character',
onAddItems: handleAddItems,
canAddMore: !isGridFull(gridType)
})
}
// Helper to check if a grid is full
function isGridFull(gridType: GridType): boolean {
if (gridType === GridType.Weapon) return weapons.length >= 10
if (gridType === GridType.Summon) return summons.length >= 6
return characters.length >= 5
}
// Job selection handlers
async function handleSelectJob() {
openJobSelectionSidebar({
currentJobId: party.job?.id,
onSelectJob: async (job) => {
// If party exists, update via API
if (partyId && shortcode) {
try {
await partyAdapter.updateJob(shortcode, job.id)
// Cache will be updated via invalidation
} catch (e) {
console.error('Failed to update job:', e)
errorMessage = e instanceof Error ? e.message : 'Failed to update job'
errorDialogOpen = true
}
} else {
// Update cache locally for new party
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
if (!old) return placeholderParty
return { ...old, job }
})
}
}
})
}
async function handleSelectJobSkill(slot: number) {
openJobSkillSelectionSidebar({
job: party.job,
currentSkills: party.jobSkills,
targetSlot: slot,
onSelectSkill: async (skill) => {
// If party exists, update via API
if (partyId && shortcode) {
try {
const updatedSkills = { ...party.jobSkills }
updatedSkills[String(slot) as keyof typeof updatedSkills] = skill
const skillsArray = transformSkillsToArray(updatedSkills)
await partyAdapter.updateJobSkills(shortcode, skillsArray)
} catch (e) {
console.error('Failed to update skill:', e)
errorMessage = e instanceof Error ? e.message : 'Failed to update skill'
errorDialogOpen = true
}
} else {
// Update cache locally for new party
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
if (!old) return placeholderParty
const updatedSkills = { ...old.jobSkills }
updatedSkills[String(slot) as keyof typeof updatedSkills] = skill
return { ...old, jobSkills: updatedSkills }
})
}
},
onRemoveSkill: async () => {
await handleRemoveJobSkill(slot)
}
})
}
async function handleRemoveJobSkill(slot: number) {
if (partyId && shortcode) {
try {
const updatedSkills = { ...party.jobSkills }
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
const skillsArray = transformSkillsToArray(updatedSkills)
await partyAdapter.updateJobSkills(shortcode, skillsArray)
} catch (e) {
console.error('Failed to remove skill:', e)
errorMessage = e instanceof Error ? e.message : 'Failed to remove skill'
errorDialogOpen = true
}
} else {
// Update cache locally for new party
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
if (!old) return placeholderParty
const updatedSkills = { ...old.jobSkills }
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
return { ...old, jobSkills: updatedSkills }
})
}
}
// Party state
let partyId = $state<string | null>(null)
let shortcode = $state<string | null>(null)
let editKey = $state<string | null>(null)
let isCreatingParty = $state(false)
// Placeholder party for 'new' route
const placeholderParty: Party = {
id: 'new',
shortcode: 'new',
name: 'New Team',
description: '',
weapons: [],
summons: [],
characters: [],
element: 0,
visibility: 1,
job: undefined,
jobSkills: undefined,
accessory: undefined
}
// Create query with placeholder data
const queryClient = useQueryClient()
const partyQuery = createQuery(() => ({
...partyQueries.byShortcode(shortcode || 'new'),
initialData: placeholderParty,
enabled: false // Disable automatic fetching for 'new' party
}))
// Derive state from query
const party = $derived(partyQuery.data ?? placeholderParty)
const weapons = $derived(party.weapons ?? [])
const summons = $derived(party.summons ?? [])
const characters = $derived(party.characters ?? [])
// Derived values for job section
const mainWeapon = $derived(weapons.find((w) => w?.mainhand || w?.position === -1))
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
const partyElement = $derived((party as any)?.element)
let selectedSlot = $state<number | null>(null)
let isFirstItemForSlot = false // Track if this is the first item after clicking empty cell
// Error dialog state
let errorDialogOpen = $state(false)
let errorMessage = $state('')
let errorDetails = $state<string[]>([])
// TanStack Query mutations
const createPartyMutation = useCreateParty()
const createWeaponMutation = useCreateGridWeapon()
const createCharacterMutation = useCreateGridCharacter()
const createSummonMutation = useCreateGridSummon()
const deleteWeapon = useDeleteGridWeapon()
const deleteCharacter = useDeleteGridCharacter()
const deleteSummon = useDeleteGridSummon()
// Helper to add item to cache
function addItemToCache(itemType: 'weapons' | 'summons' | 'characters', item: any) {
const cacheKey = partyKeys.detail(shortcode || 'new')
queryClient.setQueryData(cacheKey, (old: Party | undefined) => {
if (!old) return placeholderParty
return {
...old,
[itemType]: [...(old[itemType] ?? []), item]
}
})
}
// Calculate if grids are full
let isWeaponGridFull = $derived(weapons.length >= 10) // 1 mainhand + 9 grid slots
let isSummonGridFull = $derived(summons.length >= 6) // 6 summon slots (main + 4 grid + friend)
let isCharacterGridFull = $derived(characters.length >= 5) // 5 character slots
let canAddMore = $derived(
activeTab === GridType.Weapon ? !isWeaponGridFull :
activeTab === GridType.Summon ? !isSummonGridFull :
!isCharacterGridFull
)
// Handle adding items from search
async function handleAddItems(items: SearchResult[]) {
console.log('Adding items:', items, 'to tab:', activeTab)
// Create party on first item if not already created
if (!partyId && !isCreatingParty && items.length > 0) {
isCreatingParty = true
const firstItem = items[0]
// Guard against undefined firstItem (shouldn't happen given items.length > 0 check, but TypeScript needs this)
if (!firstItem) {
isCreatingParty = false
return
}
try {
// Step 1: Create the party (with local_id only for anonymous users)
const partyPayload: any = {
name: 'New Team',
visibility: 1, // 1 = Public, 2 = Unlisted, 3 = Private
element: firstItem.element || 0 // Use item's element or default to null
}
// Only include localId for anonymous users
if (!isAuthenticated) {
partyPayload.localId = getLocalId()
}
// Create party using mutation
const createdParty = await createPartyMutation.mutateAsync(partyPayload)
console.log('Party created:', createdParty)
// The adapter returns the party directly
partyId = createdParty.id
shortcode = createdParty.shortcode
// Store edit key for anonymous editing under BOTH identifiers
// - shortcode: for Party.svelte which uses shortcode as partyId
// - UUID: for /teams/new which uses UUID as partyId
if (createdParty.editKey) {
storeEditKey(createdParty.shortcode, createdParty.editKey)
storeEditKey(createdParty.id, createdParty.editKey)
}
if (!partyId || !shortcode) {
throw new Error('Party creation did not return ID or shortcode')
}
// Update the query cache with the created party
queryClient.setQueryData(
partyKeys.detail(createdParty.shortcode),
createdParty
)
// Step 2: Add the first item to the party
let position = selectedSlot !== null ? selectedSlot : -1 // Use selectedSlot if available
let itemAdded = false
try {
console.log('Adding item to party:', { partyId, itemId: firstItem.id, type: activeTab, position })
if (activeTab === GridType.Weapon) {
// Use selectedSlot if available, otherwise default to mainhand
if (selectedSlot === null) position = -1
const addResult = await createWeaponMutation.mutateAsync({
partyId,
weaponId: firstItem.granblueId,
position,
mainhand: position === -1
})
console.log('Weapon added:', addResult)
itemAdded = true
// Update cache with the added weapon
addItemToCache('weapons', addResult)
} else if (activeTab === GridType.Summon) {
// Use selectedSlot if available, otherwise default to main summon
if (selectedSlot === null) position = -1
const addResult = await createSummonMutation.mutateAsync({
partyId,
summonId: firstItem.granblueId,
position,
main: position === -1,
friend: position === 6
})
console.log('Summon added:', addResult)
itemAdded = true
// Update cache with the added summon
addItemToCache('summons', addResult)
} else if (activeTab === GridType.Character) {
// Use selectedSlot if available, otherwise default to first slot
if (selectedSlot === null) position = 0
const addResult = await createCharacterMutation.mutateAsync({
partyId,
characterId: firstItem.granblueId,
position
})
console.log('Character added:', addResult)
itemAdded = true
// Update cache with the added character
addItemToCache('characters', addResult)
}
selectedSlot = null // Reset after using
// Update URL without redirecting
if (itemAdded && shortcode) {
// Update the URL to reflect the new party without navigating
replaceState(`/teams/${shortcode}`, {})
// Continue to allow adding more items
}
} catch (addError: any) {
console.error('Failed to add first item:', addError)
// Show error to user but don't redirect
errorMessage = addError.message || 'Failed to add item to party'
errorDetails = addError.details || []
errorDialogOpen = true
// Still update URL to the created party even if item failed
if (shortcode) {
replaceState(`/teams/${shortcode}`, {})
}
}
isCreatingParty = false // Reset flag after party creation completes
// If there are more items to add, continue processing them
if (items.length > 1) {
const remainingItems = items.slice(1)
await handleAddItems(remainingItems) // Recursive call to add remaining items
}
return // Exit after processing all items from party creation
} catch (error: any) {
console.error('Failed to create party:', error)
isCreatingParty = false
// Parse error message
if (error.message) {
errorMessage = error.message
} else {
errorMessage = 'Failed to create party'
}
// Parse validation errors if present
if (error.details && Array.isArray(error.details)) {
errorDetails = error.details
} else if (error.errors && typeof error.errors === 'object') {
// Rails-style validation errors
errorDetails = Object.entries(error.errors).map(
([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`
)
} else {
errorDetails = []
}
errorDialogOpen = true
return
}
}
// If party already exists, add items using grid API
if (partyId && !isCreatingParty) {
try {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item) continue // Skip undefined items
let position = -1 // Default position
if (activeTab === GridType.Weapon) {
// Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !weapons.find(w => w.position === selectedSlot)) {
position = selectedSlot
selectedSlot = null // Reset after using
} else {
// Find next empty weapon slot
const emptySlots = Array.from({ length: 10 }, (_, i) => i - 1)
.filter(i => !weapons.find(w => w.position === i))
if (emptySlots.length === 0) return // Grid full
position = emptySlots[0]!
}
// Add weapon via API
const response = await createWeaponMutation.mutateAsync({
partyId,
weaponId: item.granblueId,
position,
mainhand: position === -1
})
// Add to cache
addItemToCache('weapons', response)
} else if (activeTab === GridType.Summon) {
// Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !summons.find(s => s.position === selectedSlot)) {
position = selectedSlot
selectedSlot = null // Reset after using
} else {
// Find next empty summon slot
const emptySlots = [-1, 0, 1, 2, 3, 6] // main, 4 grid slots, friend
.filter(i => !summons.find(s => s.position === i))
if (emptySlots.length === 0) return // Grid full
position = emptySlots[0]!
}
// Add summon via API
const response = await createSummonMutation.mutateAsync({
partyId,
summonId: item.granblueId,
position,
main: position === -1,
friend: position === 6
})
// Add to cache
addItemToCache('summons', response)
} else if (activeTab === GridType.Character) {
// Use selectedSlot for first item if available
if (i === 0 && selectedSlot !== null && !characters.find(c => c.position === selectedSlot)) {
position = selectedSlot
selectedSlot = null // Reset after using
} else {
// Find next empty character slot
const emptySlots = Array.from({ length: 5 }, (_, i) => i)
.filter(i => !characters.find(c => c.position === i))
if (emptySlots.length === 0) return // Grid full
position = emptySlots[0]!
}
// Add character via API
const response = await createCharacterMutation.mutateAsync({
partyId,
characterId: item.granblueId,
position
})
// Add to cache
addItemToCache('characters', response)
}
}
} catch (error: any) {
console.error('Failed to add item:', error)
errorMessage = error.message || 'Failed to add item'
errorDetails = error.details || []
errorDialogOpen = true
}
return
}
}
// Provide party context using query data
setContext('party', {
getParty: () => party,
updateParty: (p: Party) => {
// Update cache instead of local state
queryClient.setQueryData(partyKeys.detail(shortcode || 'new'), p)
},
canEdit: () => true,
getEditKey: () => editKey,
getSelectedSlot: () => selectedSlot,
getActiveTab: () => activeTab,
services: {
gridService: {
removeWeapon: async (partyId: string, itemId: string) => {
if (!partyId || partyId === 'new') return party
await deleteWeapon.mutateAsync({
id: itemId,
partyId,
partyShortcode: shortcode || 'new'
})
return party
},
removeSummon: async (partyId: string, itemId: string) => {
if (!partyId || partyId === 'new') return party
await deleteSummon.mutateAsync({
id: itemId,
partyId,
partyShortcode: shortcode || 'new'
})
return party
},
removeCharacter: async (partyId: string, itemId: string) => {
if (!partyId || partyId === 'new') return party
await deleteCharacter.mutateAsync({
id: itemId,
partyId,
partyShortcode: shortcode || 'new'
})
return party
}
}
},
openPicker: (opts: { type: 'weapon' | 'summon' | 'character'; position: number; item?: any }) => {
selectedSlot = opts.position
openSearchSidebar({
type: opts.type,
onAddItems: handleAddItems,
canAddMore: !isGridFull(
opts.type === 'weapon' ? GridType.Weapon :
opts.type === 'summon' ? GridType.Summon :
GridType.Character
)
})
}
})
</script>
<main>
<div class="page-container">
<section class="party-content">
<header class="party-header">
<div class="party-info">
<h1>Create a new team</h1>
<p class="description">Search and click items to add them to your grid</p>
</div>
<button class="toggle-sidebar" on:click={() => openSearchSidebar({
type: activeTab === GridType.Weapon ? 'weapon' :
activeTab === GridType.Summon ? 'summon' :
'character',
onAddItems: handleAddItems,
canAddMore: !isGridFull(activeTab)
})}>
Open Search
</button>
</header>
<PartySegmentedControl
selectedTab={activeTab}
onTabChange={selectTab}
party={{
id: '',
shortcode: '',
element: 0,
job: undefined,
characters,
weapons,
summons
}}
/>
<div class="party-content">
{#if activeTab === GridType.Weapon}
<WeaponGrid {weapons} />
{:else if activeTab === GridType.Summon}
<SummonGrid {summons} />
{:else}
<div class="character-tab-content">
<JobSection
job={party.job}
jobSkills={party.jobSkills}
accessory={party.accessory}
canEdit={true}
gender={Gender.Gran}
element={mainWeaponElement}
onSelectJob={handleSelectJob}
onSelectSkill={handleSelectJobSkill}
onRemoveSkill={handleRemoveJobSkill}
onSelectAccessory={() => {
console.log('Open accessory selection sidebar')
}}
/>
<CharacterGrid
{characters}
{mainWeaponElement}
{partyElement}
/>
</div>
{/if}
</div>
</section>
</div>
</main>
<!-- Error Dialog -->
<Dialog.Root bind:open={errorDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<Dialog.Title class="dialog-title">Error Creating Team</Dialog.Title>
<Dialog.Description class="dialog-description">
{errorMessage}
</Dialog.Description>
{#if errorDetails.length > 0}
<div class="error-details">
<p class="error-details-title">Details:</p>
<ul class="error-list">
{#each errorDetails as detail}
<li>{detail}</li>
{/each}
</ul>
</div>
{/if}
<div class="dialog-actions">
<Dialog.Close class="dialog-button">
OK
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style>
/* Override the main element's padding for this page */
:global(main) {
padding: 0 !important;
}
.page-container {
display: flex;
gap: 0;
width: 100%;
min-height: 100vh;
}
.party-content {
flex: 1;
padding: 1rem 2rem;
}
.party-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1rem;
}
.party-info h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.description {
color: #666;
margin: 0;
}
.toggle-sidebar {
padding: 0.5rem 1rem;
background: #3366ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.toggle-sidebar:hover {
background: #2857e0;
}
.party-content {
min-height: 400px;
}
.character-tab-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Dialog styles */
:global(.dialog-overlay) {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
:global(.dialog-content) {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
z-index: 51;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #d32f2f;
}
.dialog-description {
color: #666;
margin-bottom: 16px;
line-height: 1.5;
}
.error-details {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
margin-bottom: 20px;
}
.error-details-title {
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.error-list {
margin: 0;
padding-left: 20px;
}
.error-list li {
color: #666;
margin-bottom: 4px;
list-style: disc;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
}
.dialog-button {
padding: 8px 16px;
background: #3366ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.dialog-button:hover {
background: #2857e0;
}
</style>

View file

@ -0,0 +1,10 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ parent }) => {
const parentData = await parent()
return {
isAuthenticated: parentData.isAuthenticated ?? false,
currentUser: parentData.currentUser ?? null
}
}

View file

@ -0,0 +1,15 @@
<script>
// Simple layout for test pages
</script>
<div class="test-layout">
<slot />
</div>
<style>
.test-layout {
min-height: 100vh;
background: var(--background);
color: var(--text-primary);
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte'
import MenuItems from '$lib/components/ui/menu/MenuItems.svelte'
let message = $state('No action yet')
function handleViewDetails() {
message = 'View Details clicked'
}
function handleReplace() {
message = 'Replace clicked'
}
function handleRemove() {
message = 'Remove clicked'
}
</script>
<div class="test-page">
<h1>Context Menu Test Page</h1>
<p class="instructions">
Test both interaction methods:
</p>
<ul class="instructions">
<li><strong>Right-click</strong> on the weapon image to open the context menu</li>
<li><strong>Hover</strong> over the weapon to see the gear button appear</li>
<li><strong>Click</strong> the gear button to open the dropdown menu</li>
</ul>
<div class="test-container">
<div class="test-unit">
<UnitMenuContainer showGearButton={true}>
{#snippet trigger()}
<img
src="/images/placeholders/placeholder-weapon-grid.png"
alt="Test weapon"
class="test-image"
/>
{/snippet}
{#snippet contextMenu()}
<MenuItems
onViewDetails={handleViewDetails}
onReplace={handleReplace}
onRemove={handleRemove}
canEdit={true}
variant="context"
viewDetailsLabel="View Details"
replaceLabel="Replace"
removeLabel="Remove"
/>
{/snippet}
{#snippet dropdownMenu()}
<MenuItems
onViewDetails={handleViewDetails}
onReplace={handleReplace}
onRemove={handleRemove}
canEdit={true}
variant="dropdown"
viewDetailsLabel="View Details"
replaceLabel="Replace"
removeLabel="Remove"
/>
{/snippet}
</UnitMenuContainer>
<div class="test-label">Hover me or right-click</div>
</div>
</div>
<div class="result">
<strong>Last action:</strong> {message}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.test-page {
padding: $unit-4x;
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: $font-xxlarge;
margin-bottom: $unit-3x;
}
.instructions {
margin-bottom: $unit-3x;
line-height: 1.6;
&.instructions {
padding-left: $unit-3x;
}
}
.test-container {
display: flex;
justify-content: center;
padding: $unit-8x;
background: var(--app-bg-secondary);
border-radius: $unit;
margin-bottom: $unit-3x;
}
.test-unit {
width: 200px;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
cursor: pointer;
}
.test-image {
width: 100%;
border-radius: $unit;
background: var(--card-bg);
position: relative;
display: block;
}
.test-label {
font-size: $font-small;
color: var(--text-secondary);
text-align: center;
}
.result {
padding: $unit-2x;
background: var(--card-bg);
border-radius: $unit;
font-size: $font-regular;
}
</style>

View file

@ -0,0 +1,450 @@
<script lang="ts">
import {
getImageUrl,
getCharacterPose,
type ResourceType,
type ImageVariant
} from '$lib/utils/images'
// State for selections
let resourceType: ResourceType = $state('character')
let variant: ImageVariant = $state('main')
let itemId = $state('3030182000') // Gran/Djeeta as default
let pose = $state('01')
let uncapLevel = $state(0)
let transcendenceStep = $state(0)
let weaponElement = $state(0)
let customPose = $state(false)
// Sample item IDs for testing
const sampleIds = {
character: [
{ id: '3030182000', name: 'Gran/Djeeta (Element-specific)' },
{ id: '3020000000', name: 'Katalina' },
{ id: '3020001000', name: 'Rackam' },
{ id: '3020002000', name: 'Io' },
{ id: '3040000000', name: 'Charlotta' }
],
weapon: [
{ id: '1040000000', name: 'Sword' },
{ id: '1040001000', name: 'Luminiera Sword' },
{ id: '1040500000', name: 'Bahamut Sword' },
{ id: '1040019000', name: 'Opus Sword' }
],
summon: [
{ id: '2040000000', name: 'Colossus' },
{ id: '2040001000', name: 'Leviathan' },
{ id: '2040002000', name: 'Tiamat' },
{ id: '2040003000', name: 'Yggdrasil' }
]
}
// Available variants per resource type
const availableVariants = $derived.by(() => {
const base: ImageVariant[] = ['main', 'grid', 'square']
if (resourceType === 'character') {
return [...base, 'detail']
} else if (resourceType === 'weapon') {
return [...base, 'base']
} else {
return [...base, 'detail', 'wide']
}
})
// Auto-calculate pose based on uncap/transcendence
const calculatedPose = $derived(
customPose ? pose : getCharacterPose(uncapLevel, transcendenceStep)
)
// Handle Gran/Djeeta element-specific poses
const finalPose = $derived.by(() => {
if (resourceType !== 'character') return undefined
let p = calculatedPose
if (itemId === '3030182000' && weaponElement > 0) {
p = `${p}_0${weaponElement}`
}
return p
})
// Generated image URL
const imageUrl = $derived(
getImageUrl(resourceType as ResourceType, itemId || null, variant as ImageVariant, {
pose: finalPose,
element: (resourceType as ResourceType) === 'weapon' && (variant as ImageVariant) === 'grid' ? weaponElement : undefined
})
)
// File extension display
const fileExtension = $derived.by(() => {
if (resourceType === 'character' && variant === 'detail') return '.png'
if (resourceType === 'weapon' && variant === 'base') return '.png'
if (resourceType === 'summon' && variant === 'detail') return '.png'
return '.jpg'
})
// Reset variant if not available
$effect(() => {
if (!availableVariants.includes(variant)) {
variant = 'main'
}
})
</script>
<div class="test-container">
<h1>Image Utility Test Page</h1>
<div class="controls">
<section>
<h2>Resource Type</h2>
<div class="radio-group">
{#each ['character', 'weapon', 'summon'] as type}
<label>
<input type="radio" bind:group={resourceType} value={type} />
{type.charAt(0).toUpperCase() + type.slice(1)}
</label>
{/each}
</div>
</section>
<section>
<h2>Image Variant</h2>
<div class="radio-group">
{#each availableVariants as v}
<label class:special={fileExtension === '.png' && variant === v}>
<input type="radio" bind:group={variant} value={v} />
{v.charAt(0).toUpperCase() + v.slice(1)}
{#if (resourceType === 'character' && v === 'detail') || (resourceType === 'weapon' && v === 'base') || (resourceType === 'summon' && v === 'detail')}
<span class="badge">PNG</span>
{/if}
</label>
{/each}
</div>
</section>
<section>
<h2>Item Selection</h2>
<div class="radio-group">
<label>
<input type="radio" bind:group={itemId} value="" />
None (Placeholder)
</label>
{#each sampleIds[resourceType] as item}
<label>
<input type="radio" bind:group={itemId} value={item.id} />
{item.name}
</label>
{/each}
</div>
<div class="custom-id">
<label>
Custom ID:
<input type="text" bind:value={itemId} placeholder="Enter Granblue ID" />
</label>
</div>
</section>
{#if resourceType === 'character'}
<section>
<h2>Character Pose</h2>
<div class="checkbox-group">
<label>
<input type="checkbox" bind:checked={customPose} />
Manual pose control
</label>
</div>
{#if customPose}
<div class="radio-group">
{#each ['01', '02', '03', '04'] as p}
<label>
<input type="radio" bind:group={pose} value={p} />
Pose {p}
</label>
{/each}
</div>
{:else}
<div class="slider-group">
<label>
Uncap Level: {uncapLevel}
<input type="range" bind:value={uncapLevel} min="0" max="6" />
</label>
<label>
Transcendence: {transcendenceStep}
<input type="range" bind:value={transcendenceStep} min="0" max="5" />
</label>
<div class="info">Calculated Pose: {calculatedPose}</div>
</div>
{/if}
{#if itemId === '3030182000'}
<div class="element-group">
<h3>Gran/Djeeta Element</h3>
<div class="radio-group">
{#each [{ value: 0, label: 'None' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
<label>
<input type="radio" bind:group={weaponElement} value={elem.value} />
{elem.label}
</label>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{#if resourceType === 'weapon' && variant === 'grid'}
<section>
<h2>Weapon Element (Grid Only)</h2>
<div class="radio-group">
{#each [{ value: 0, label: 'Default' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
<label>
<input type="radio" bind:group={weaponElement} value={elem.value} />
{elem.label}
</label>
{/each}
</div>
</section>
{/if}
</div>
<div class="output">
<section class="url-display">
<h2>Generated URL</h2>
<code>{imageUrl}</code>
<div class="path-info">
<span>Directory: <strong>{resourceType}-{variant}</strong></span>
<span>Extension: <strong>{fileExtension}</strong></span>
</div>
</section>
<section class="image-display">
<h2>Image Preview</h2>
<div class="image-container" data-variant={variant}>
<img
src={imageUrl}
alt="Test image"
on:error={(e) => {
e.currentTarget.classList.add('error')
}}
on:load={(e) => {
e.currentTarget.classList.remove('error')
}}
/>
</div>
<p class="note">Note: Image will show error state if file doesn't exist</p>
</section>
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
.test-container {
padding: $unit-2x;
max-width: 1400px;
margin: 0 auto;
}
h1 {
margin-bottom: $unit-3x;
color: var(--text-primary);
}
h2 {
font-size: $font-large;
margin-bottom: $unit;
color: var(--text-secondary);
}
h3 {
font-size: $font-regular;
margin-bottom: $unit-half;
color: var(--text-secondary);
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $unit-2x;
margin-bottom: $unit-3x;
}
section {
background: var(--background-secondary, $grey-90);
border: 1px solid var(--border-color, $grey-80);
border-radius: $card-corner;
padding: $unit-2x;
}
.radio-group,
.checkbox-group {
display: flex;
flex-direction: column;
gap: $unit-half;
label {
display: flex;
align-items: center;
gap: $unit-half;
cursor: pointer;
padding: $unit-half;
border-radius: $item-corner-small;
transition: background-color 0.2s;
&:hover {
background: var(--background-hover, rgba(255, 255, 255, 0.05));
}
&.special {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
}
input {
margin: 0;
}
.badge {
font-size: $font-tiny;
padding: 2px 6px;
background: rgba(59, 130, 246, 0.2);
color: rgb(59, 130, 246);
border-radius: 4px;
margin-left: auto;
}
}
}
.custom-id {
margin-top: $unit;
padding-top: $unit;
border-top: 1px solid var(--border-color, $grey-80);
label {
display: flex;
flex-direction: column;
gap: $unit-half;
}
input[type='text'] {
padding: $unit-half $unit;
background: var(--input-bg, $grey-95);
border: 1px solid var(--border-color, $grey-80);
border-radius: $input-corner;
color: var(--text-primary);
font-family: monospace;
&:focus {
outline: none;
border-color: var(--accent-blue, #3b82f6);
}
}
}
.slider-group {
display: flex;
flex-direction: column;
gap: $unit;
label {
display: flex;
flex-direction: column;
gap: $unit-half;
}
input[type='range'] {
width: 100%;
}
.info {
padding: $unit-half;
background: rgba(59, 130, 246, 0.1);
border-radius: $item-corner-small;
color: var(--text-primary);
font-weight: $medium;
}
}
.element-group {
margin-top: $unit;
padding-top: $unit;
border-top: 1px solid var(--border-color, $grey-80);
}
.output {
display: grid;
gap: $unit-2x;
}
.url-display {
code {
display: block;
padding: $unit;
background: var(--code-bg, $grey-95);
border-radius: $item-corner-small;
font-family: monospace;
font-size: $font-small;
word-break: break-all;
color: var(--text-primary);
margin-bottom: $unit;
}
.path-info {
display: flex;
gap: $unit-2x;
font-size: $font-small;
color: var(--text-secondary);
strong {
color: var(--text-primary);
font-family: monospace;
}
}
}
.image-display {
.image-container {
background: $grey-95;
border: 2px dashed $grey-80;
border-radius: $card-corner;
padding: $unit-2x;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
&[data-variant='detail'],
&[data-variant='base'] {
min-height: 400px;
}
&[data-variant='wide'] {
min-height: 150px;
}
img {
max-width: 100%;
height: auto;
display: block;
border-radius: $item-corner;
&.error {
opacity: 0.3;
filter: grayscale(1);
border: 2px solid red;
}
}
}
.note {
margin-top: $unit;
font-size: $font-small;
color: var(--text-secondary);
font-style: italic;
}
}
</style>