remove old routes (moved to app group)
This commit is contained in:
parent
b78ee7ca20
commit
9ace6f0862
37 changed files with 0 additions and 4114 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
<h1>Welcome to SvelteKit</h1>
|
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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, '/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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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, '/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = locals.session.account?.role ?? 0
|
|
||||||
if (role < 7) {
|
|
||||||
throw redirect(302, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { redirect } from '@sveltejs/kit'
|
|
||||||
import type { PageLoad } from './$types'
|
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
|
||||||
throw redirect(302, '/database/weapons')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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, '/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = locals.session.account?.role ?? 0
|
|
||||||
if (role < 7) {
|
|
||||||
throw redirect(302, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,596 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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, '/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = locals.session.account?.role ?? 0
|
|
||||||
if (role < 7) {
|
|
||||||
throw redirect(302, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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, '/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = locals.session.account?.role ?? 0
|
|
||||||
if (role < 7) {
|
|
||||||
throw redirect(302, '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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, `/login?redirect=${encodeURIComponent(url.pathname)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
currentUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,366 +0,0 @@
|
||||||
<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('/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>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,820 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { PageLoad } from './$types'
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ parent }) => {
|
|
||||||
const parentData = await parent()
|
|
||||||
|
|
||||||
return {
|
|
||||||
isAuthenticated: parentData.isAuthenticated ?? false,
|
|
||||||
currentUser: parentData.currentUser ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
<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>
|
|
||||||
Loading…
Reference in a new issue