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