move routes to (app) route group
This commit is contained in:
parent
3498b7d966
commit
b78ee7ca20
38 changed files with 4401 additions and 0 deletions
287
src/routes/(app)/+layout.svelte
Normal file
287
src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Navigation from '$lib/components/Navigation.svelte'
|
||||||
|
import Sidebar from '$lib/components/ui/Sidebar.svelte'
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import { Tooltip } from 'bits-ui'
|
||||||
|
import { beforeNavigate, afterNavigate } from '$app/navigation'
|
||||||
|
import { browser, dev } from '$app/environment'
|
||||||
|
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools'
|
||||||
|
import type { LayoutData } from './$types'
|
||||||
|
|
||||||
|
const { data, children } = $props<{
|
||||||
|
data: LayoutData & { [key: string]: any }
|
||||||
|
children: () => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Reference to the scrolling container
|
||||||
|
let mainContent: HTMLElement | undefined
|
||||||
|
|
||||||
|
// Store scroll positions for each visited route
|
||||||
|
const scrollPositions = new Map<string, number>()
|
||||||
|
|
||||||
|
// Save scroll position before navigating away and close sidebar
|
||||||
|
beforeNavigate(({ from }) => {
|
||||||
|
// Close sidebar when navigating
|
||||||
|
sidebar.close()
|
||||||
|
|
||||||
|
// Save scroll position for the current route
|
||||||
|
if (from && mainContent) {
|
||||||
|
const key = from.url.pathname + from.url.search
|
||||||
|
scrollPositions.set(key, mainContent.scrollTop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle scroll restoration or reset after navigation
|
||||||
|
afterNavigate(({ from, to, type }) => {
|
||||||
|
if (!mainContent || !to) return
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to ensure DOM has updated
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!mainContent) return
|
||||||
|
const key = to.url.pathname + to.url.search
|
||||||
|
|
||||||
|
// Only restore scroll for browser back/forward navigation
|
||||||
|
if (type === 'popstate' && scrollPositions.has(key)) {
|
||||||
|
// User clicked back/forward button - restore their position
|
||||||
|
mainContent.scrollTop = scrollPositions.get(key) || 0
|
||||||
|
} else {
|
||||||
|
// Any other navigation type (link, goto, enter, etc.) - go to top
|
||||||
|
mainContent.scrollTop = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optional: Export snapshot for session persistence
|
||||||
|
export const snapshot = {
|
||||||
|
capture: () => {
|
||||||
|
if (!mainContent) return { scroll: 0, positions: [] }
|
||||||
|
return {
|
||||||
|
scroll: mainContent.scrollTop,
|
||||||
|
positions: Array.from(scrollPositions.entries())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restore: (snapshotData: { scroll?: number; positions?: [string, number][] }) => {
|
||||||
|
if (!snapshotData || !mainContent) return
|
||||||
|
|
||||||
|
// Restore saved positions map
|
||||||
|
if (snapshotData.positions) {
|
||||||
|
scrollPositions.clear()
|
||||||
|
snapshotData.positions.forEach(([key, value]) => {
|
||||||
|
scrollPositions.set(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore current scroll position after DOM is ready
|
||||||
|
if (browser) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (mainContent) mainContent.scrollTop = snapshotData.scroll || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dev}
|
||||||
|
<SvelteQueryDevtools />
|
||||||
|
{/if}
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
|
||||||
|
<div class="main-pane">
|
||||||
|
<div class="nav-blur-background"></div>
|
||||||
|
<div class="main-navigation">
|
||||||
|
<Navigation
|
||||||
|
isAuthenticated={data?.isAuthenticated}
|
||||||
|
account={data?.account}
|
||||||
|
currentUser={data?.currentUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<main class="main-content" bind:this={mainContent}>
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
open={sidebar.isOpen}
|
||||||
|
title={sidebar.title}
|
||||||
|
onclose={() => sidebar.close()}
|
||||||
|
scrollable={sidebar.scrollable}
|
||||||
|
onsave={sidebar.onsave}
|
||||||
|
saveLabel={sidebar.saveLabel}
|
||||||
|
element={sidebar.element}
|
||||||
|
onback={sidebar.onback}
|
||||||
|
>
|
||||||
|
{#if sidebar.component}
|
||||||
|
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
|
||||||
|
{:else if sidebar.content}
|
||||||
|
{@render sidebar.content()}
|
||||||
|
{/if}
|
||||||
|
</Sidebar>
|
||||||
|
</div>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Main pane with content
|
||||||
|
.main-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
transition: margin-right $duration-slide ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Blur background that shifts with main pane
|
||||||
|
.nav-blur-background {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 81px; // Matches $nav-height
|
||||||
|
z-index: 1; // Lower z-index so scrollbar appears above
|
||||||
|
pointer-events: none;
|
||||||
|
transition: right $duration-slide ease-in-out;
|
||||||
|
|
||||||
|
// Color gradient for the background
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
color-mix(in srgb, var(--background) 85%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--background) 60%, transparent) 50%,
|
||||||
|
color-mix(in srgb, var(--background) 20%, transparent) 85%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single blur value applied to entire element
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|
||||||
|
// Mask gradient to fade out the blur effect progressively
|
||||||
|
mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation wrapper - fixed but shifts with main-pane
|
||||||
|
.main-navigation {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10; // Above blur but below scrollbar
|
||||||
|
transition: right $duration-slide ease-in-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content area with independent scroll
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 81px; // Space for fixed navigation (matches $nav-height)
|
||||||
|
padding-bottom: 20vh; // Extra space at bottom for comfortable scrolling
|
||||||
|
z-index: 2; // Ensure scrollbar is above blur background
|
||||||
|
|
||||||
|
// Use overlay scrollbars that auto-hide on macOS
|
||||||
|
overflow-y: overlay;
|
||||||
|
|
||||||
|
// Thin, minimal scrollbar styling
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firefox scrollbar styling
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When sidebar is open, adjust main pane and navigation
|
||||||
|
&.sidebar-open {
|
||||||
|
.main-pane {
|
||||||
|
margin-right: var(--sidebar-width, 420px);
|
||||||
|
|
||||||
|
// Blur background and navigation shift with the main pane
|
||||||
|
.nav-blur-background,
|
||||||
|
.main-navigation {
|
||||||
|
right: var(--sidebar-width, 420px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: don't adjust margin, use overlay
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
margin-right: 0;
|
||||||
|
|
||||||
|
.nav-blur-background,
|
||||||
|
.main-navigation {
|
||||||
|
right: 0; // Don't shift on mobile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-container {
|
||||||
|
.main-pane {
|
||||||
|
.main-content {
|
||||||
|
// Improve mobile scrolling performance
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay backdrop when sidebar is open on mobile
|
||||||
|
&.sidebar-open::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 99;
|
||||||
|
animation: fadeIn $duration-quick ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in animation for mobile backdrop
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
src/routes/(app)/+page.svelte
Normal file
2
src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>Welcome to SvelteKit</h1>
|
||||||
|
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||||
35
src/routes/(app)/[username]/+page.server.ts
Normal file
35
src/routes/(app)/[username]/+page.server.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { error } from '@sveltejs/kit'
|
||||||
|
import { userAdapter } from '$lib/api/adapters/user.adapter'
|
||||||
|
import { parseParty } from '$lib/api/schemas/party'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, url, depends, locals }) => {
|
||||||
|
depends('app:profile')
|
||||||
|
const username = params.username
|
||||||
|
const pageParam = url.searchParams.get('page')
|
||||||
|
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
|
||||||
|
const tab = url.searchParams.get('tab') === 'favorites' ? 'favorites' : 'teams'
|
||||||
|
const isOwner = locals.session?.account?.username === username
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tab === 'favorites' && isOwner) {
|
||||||
|
const fav = await userAdapter.getFavorites({ page })
|
||||||
|
return {
|
||||||
|
user: { username } as any,
|
||||||
|
items: fav.items,
|
||||||
|
page: fav.page,
|
||||||
|
total: fav.total,
|
||||||
|
totalPages: fav.totalPages,
|
||||||
|
perPage: fav.perPage,
|
||||||
|
tab,
|
||||||
|
isOwner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, items, total, totalPages, perPage } = await userAdapter.getProfile(username, page)
|
||||||
|
const parties = items.map((p) => parseParty(p))
|
||||||
|
return { user, items: parties, page, total, totalPages, perPage, tab, isOwner }
|
||||||
|
} catch (e: any) {
|
||||||
|
throw error(e?.status || 502, e?.message || 'Failed to load profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/routes/(app)/[username]/+page.svelte
Normal file
263
src/routes/(app)/[username]/+page.svelte
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
|
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||||
|
import { userQueries } from '$lib/api/queries/user.queries'
|
||||||
|
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||||
|
import { IsInViewport } from 'runed'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
||||||
|
const { data } = $props() as { data: PageData }
|
||||||
|
const tab = $derived(data.tab || 'teams')
|
||||||
|
const isOwner = $derived(data.isOwner || false)
|
||||||
|
|
||||||
|
const avatarFile = $derived(data.user?.avatar?.picture || '')
|
||||||
|
const avatarSrc = $derived(getAvatarSrc(avatarFile))
|
||||||
|
const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
|
||||||
|
|
||||||
|
// Note: Type assertion needed because favorites and parties queries have different
|
||||||
|
// result structures (items vs results) but we handle both in the items $derived
|
||||||
|
const partiesQuery = createInfiniteQuery(() => {
|
||||||
|
const isFavorites = tab === 'favorites' && isOwner
|
||||||
|
|
||||||
|
if (isFavorites) {
|
||||||
|
return {
|
||||||
|
...userQueries.favorites(),
|
||||||
|
initialData: data.items
|
||||||
|
? {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
items: data.items,
|
||||||
|
page: data.page || 1,
|
||||||
|
totalPages: data.totalPages,
|
||||||
|
total: data.total,
|
||||||
|
perPage: data.perPage || 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pageParams: [1]
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
initialDataUpdatedAt: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userQueries.parties(data.user?.username ?? ''),
|
||||||
|
enabled: !!data.user?.username,
|
||||||
|
initialData: data.items
|
||||||
|
? {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
results: data.items,
|
||||||
|
page: data.page || 1,
|
||||||
|
totalPages: data.totalPages,
|
||||||
|
total: data.total,
|
||||||
|
perPage: data.perPage || 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pageParams: [1]
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
initialDataUpdatedAt: 0
|
||||||
|
} as unknown as ReturnType<typeof userQueries.favorites>
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = $derived(() => {
|
||||||
|
if (!partiesQuery.data?.pages) return data.items || []
|
||||||
|
const isFavorites = tab === 'favorites' && isOwner
|
||||||
|
if (isFavorites) {
|
||||||
|
return partiesQuery.data.pages.flatMap((page) => (page as any).items ?? [])
|
||||||
|
}
|
||||||
|
return partiesQuery.data.pages.flatMap((page) => (page as any).results ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEmpty = $derived(!partiesQuery.isLoading && items().length === 0)
|
||||||
|
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
|
||||||
|
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
|
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||||
|
rootMargin: '300px'
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
inViewport.current &&
|
||||||
|
partiesQuery.hasNextPage &&
|
||||||
|
!partiesQuery.isFetchingNextPage &&
|
||||||
|
!partiesQuery.isLoading
|
||||||
|
) {
|
||||||
|
partiesQuery.fetchNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="profile">
|
||||||
|
<header class="header">
|
||||||
|
{#if data.user?.avatar?.picture}
|
||||||
|
<img
|
||||||
|
class="avatar"
|
||||||
|
alt={`Avatar of ${data.user.username}`}
|
||||||
|
src={avatarSrc}
|
||||||
|
srcset={avatarSrcSet}
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="avatar" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h1>{data.user.username}</h1>
|
||||||
|
<nav class="tabs" aria-label="Profile sections">
|
||||||
|
<a class:active={tab === 'teams'} href="?tab=teams" data-sveltekit-preload-data="hover"
|
||||||
|
>Teams</a
|
||||||
|
>
|
||||||
|
{#if isOwner}
|
||||||
|
<a
|
||||||
|
class:active={tab === 'favorites'}
|
||||||
|
href="?tab=favorites"
|
||||||
|
data-sveltekit-preload-data="hover">Favorites</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if partiesQuery.isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading {tab}...</p>
|
||||||
|
</div>
|
||||||
|
{:else if partiesQuery.isError}
|
||||||
|
<div class="error">
|
||||||
|
<Icon name="alert-circle" size={32} />
|
||||||
|
<p>Failed to load {tab}: {partiesQuery.error?.message || 'Unknown error'}</p>
|
||||||
|
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else if isEmpty}
|
||||||
|
<div class="empty">
|
||||||
|
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="profile-grid">
|
||||||
|
<ExploreGrid items={items()} />
|
||||||
|
|
||||||
|
{#if showSentinel}
|
||||||
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if partiesQuery.isFetchingNextPage}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !partiesQuery.hasNextPage && items().length > 0}
|
||||||
|
<div class="end">
|
||||||
|
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $grey-80;
|
||||||
|
border: 1px solid $grey-75;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
color: $grey-55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-top: $unit-half;
|
||||||
|
}
|
||||||
|
.tabs a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tabs a.active {
|
||||||
|
border-color: #3366ff;
|
||||||
|
color: #3366ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.end,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--text-error, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/routes/(app)/about/+page.svelte
Normal file
9
src/routes/(app)/about/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages'
|
||||||
|
export let data: { status: unknown; seo: string }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>{@html data.seo}</svelte:head>
|
||||||
|
|
||||||
|
<h1>{m.hello_world({ name: 'World' })}</h1>
|
||||||
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
8
src/routes/(app)/about/+page.ts
Normal file
8
src/routes/(app)/about/+page.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { PUBLIC_SIERO_API_URL } from '$env/static/public'
|
||||||
|
|
||||||
|
export const load = async ({ fetch }) => {
|
||||||
|
const apiBase = PUBLIC_SIERO_API_URL || 'http://localhost:3000'
|
||||||
|
const response = await fetch(`${apiBase}/api/v1/version`)
|
||||||
|
const status = await response.json()
|
||||||
|
return { status }
|
||||||
|
}
|
||||||
25
src/routes/(app)/api/settings/+server.ts
Normal file
25
src/routes/(app)/api/settings/+server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { json } from '@sveltejs/kit'
|
||||||
|
import type { RequestHandler } from './$types'
|
||||||
|
import { setUserCookie } from '$lib/auth/cookies'
|
||||||
|
import type { UserCookie } from '$lib/types/UserCookie'
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||||
|
try {
|
||||||
|
const userCookie = await request.json() as UserCookie
|
||||||
|
|
||||||
|
// Calculate expiry date (60 days from now)
|
||||||
|
const expires = new Date()
|
||||||
|
expires.setDate(expires.getDate() + 60)
|
||||||
|
|
||||||
|
// Set the user cookie with the updated data
|
||||||
|
setUserCookie(cookies, userCookie, {
|
||||||
|
secure: true,
|
||||||
|
expires
|
||||||
|
})
|
||||||
|
|
||||||
|
return json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update settings cookie:', error)
|
||||||
|
return json({ error: 'Failed to update settings' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/routes/(app)/collection/+page.svelte
Normal file
31
src/routes/(app)/collection/+page.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Collection</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Collection</h1>
|
||||||
|
<p>Your collection in Granblue Fantasy</p>
|
||||||
|
<!-- Content will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
src/routes/(app)/database/+layout.server.ts
Normal file
21
src/routes/(app)/database/+layout.server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { LayoutServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||||
|
// Check authentication first
|
||||||
|
if (!locals.session.isAuthenticated) {
|
||||||
|
throw redirect(302, '/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role authorization
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
// Redirect to home with no indication of why (security best practice)
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
user: locals.session.user
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/routes/(app)/database/+layout.svelte
Normal file
30
src/routes/(app)/database/+layout.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { localizeHref } from '$lib/paraglide/runtime'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
const { data, children }: { data: PageData; children: () => any } = $props()
|
||||||
|
|
||||||
|
const baseHref = localizeHref('/database')
|
||||||
|
const summonsHref = localizeHref('/database/summons')
|
||||||
|
const charactersHref = localizeHref('/database/characters')
|
||||||
|
const weaponsHref = localizeHref('/database/weapons')
|
||||||
|
|
||||||
|
// Function to check if a nav item is selected based on current path
|
||||||
|
function isSelected(href: string): boolean {
|
||||||
|
return $page.url.pathname === href || $page.url.pathname.startsWith(href + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's element for styling
|
||||||
|
const userElement = $derived((data as any)?.user?.element || 'null')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
</style>
|
||||||
16
src/routes/(app)/database/+page.server.ts
Normal file
16
src/routes/(app)/database/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// Double-check authorization at page level
|
||||||
|
if (!locals.session.isAuthenticated) {
|
||||||
|
throw redirect(302, '/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
7
src/routes/(app)/database/+page.ts
Normal file
7
src/routes/(app)/database/+page.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
|
throw redirect(302, '/database/weapons')
|
||||||
|
}
|
||||||
|
|
||||||
16
src/routes/(app)/database/characters/+page.server.ts
Normal file
16
src/routes/(app)/database/characters/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// Enforce authorization at individual page level
|
||||||
|
if (!locals.session.isAuthenticated) {
|
||||||
|
throw redirect(302, '/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
76
src/routes/(app)/database/characters/+page.svelte
Normal file
76
src/routes/(app)/database/characters/+page.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Svelte components
|
||||||
|
import CharacterImageCell from '$lib/components/database/cells/CharacterImageCell.svelte'
|
||||||
|
import CharacterUncapCell from '$lib/components/database/cells/CharacterUncapCell.svelte'
|
||||||
|
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
|
||||||
|
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
|
||||||
|
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
import { getRarityLabel } from '$lib/utils/rarity'
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'granblueId',
|
||||||
|
header: 'Image',
|
||||||
|
width: 80,
|
||||||
|
cell: CharacterImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj: { en: any; ja: any }) => {
|
||||||
|
if (!nameObj) return '—'
|
||||||
|
if (typeof nameObj === 'string') return nameObj
|
||||||
|
// Handle {en: "...", ja: "..."} structure
|
||||||
|
return nameObj.en || nameObj.ja || '—'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rarity',
|
||||||
|
header: 'Rarity',
|
||||||
|
width: 80,
|
||||||
|
sort: true,
|
||||||
|
template: (rarity: number) => getRarityLabel(rarity)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
cell: ElementCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uncap',
|
||||||
|
header: 'Uncap',
|
||||||
|
width: 160,
|
||||||
|
cell: CharacterUncapCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_updated',
|
||||||
|
header: 'Last Updated',
|
||||||
|
width: 120,
|
||||||
|
sort: true,
|
||||||
|
cell: LastUpdatedCell
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<DatabaseGridWithProvider resource="characters" {columns} pageSize={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/(app)/database/characters/[id]/+page.server.ts
Normal file
29
src/routes/(app)/database/characters/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||||
|
import { error } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
|
try {
|
||||||
|
// Get parent data to access role
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
const character = await entityAdapter.getCharacter(params.id)
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw error(404, 'Character not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
character,
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load character:', err)
|
||||||
|
|
||||||
|
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||||
|
throw error(404, 'Character not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, 'Failed to load character')
|
||||||
|
}
|
||||||
|
}
|
||||||
596
src/routes/(app)/database/characters/[id]/+page.svelte
Normal file
596
src/routes/(app)/database/characters/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// SvelteKit imports
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||||
|
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
|
||||||
|
import { getElementLabel, getElementOptions } from '$lib/utils/element'
|
||||||
|
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
|
||||||
|
import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
|
||||||
|
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
|
||||||
|
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||||
|
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import { getCharacterImage } from '$lib/utils/images'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Use TanStack Query with SSR initial data
|
||||||
|
const characterQuery = createQuery(() => ({
|
||||||
|
...entityQueries.character(data.character?.id ?? ''),
|
||||||
|
...withInitialData(data.character)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get character from query
|
||||||
|
const character = $derived(characterQuery.data)
|
||||||
|
const userRole = $derived(data.role || 0)
|
||||||
|
const canEdit = $derived(userRole >= 7)
|
||||||
|
|
||||||
|
// Edit mode state
|
||||||
|
let editMode = $state(false)
|
||||||
|
|
||||||
|
// Query for related characters (same character_id)
|
||||||
|
const relatedQuery = createQuery(() => ({
|
||||||
|
queryKey: ['characters', 'related', character?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!character?.id) return []
|
||||||
|
return entityAdapter.getRelatedCharacters(character.id)
|
||||||
|
},
|
||||||
|
enabled: !!character?.characterId && !editMode
|
||||||
|
}))
|
||||||
|
let isSaving = $state(false)
|
||||||
|
let saveError = $state<string | null>(null)
|
||||||
|
let saveSuccess = $state(false)
|
||||||
|
|
||||||
|
// Editable fields - create reactive state for each field
|
||||||
|
let editData = $state({
|
||||||
|
name: character?.name || '',
|
||||||
|
granblueId: character?.granblueId || '',
|
||||||
|
characterId: character?.characterId ?? (null as number | null),
|
||||||
|
rarity: character?.rarity || 1,
|
||||||
|
element: character?.element || 0,
|
||||||
|
race1: character?.race?.[0] ?? null,
|
||||||
|
race2: character?.race?.[1] ?? null,
|
||||||
|
gender: character?.gender || 0,
|
||||||
|
proficiency1: character?.proficiency?.[0] || 0,
|
||||||
|
proficiency2: character?.proficiency?.[1] || 0,
|
||||||
|
minHp: character?.hp?.minHp || 0,
|
||||||
|
maxHp: character?.hp?.maxHp || 0,
|
||||||
|
maxHpFlb: character?.hp?.maxHpFlb || 0,
|
||||||
|
minAtk: character?.atk?.minAtk || 0,
|
||||||
|
maxAtk: character?.atk?.maxAtk || 0,
|
||||||
|
maxAtkFlb: character?.atk?.maxAtkFlb || 0,
|
||||||
|
flb: character?.uncap?.flb || false,
|
||||||
|
ulb: character?.uncap?.ulb || false,
|
||||||
|
transcendence: character?.uncap?.transcendence || false,
|
||||||
|
special: character?.special || false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset edit data when character changes
|
||||||
|
$effect(() => {
|
||||||
|
if (character) {
|
||||||
|
editData = {
|
||||||
|
name: character.name || '',
|
||||||
|
granblueId: character.granblueId || '',
|
||||||
|
characterId: character.characterId ?? null,
|
||||||
|
rarity: character.rarity || 1,
|
||||||
|
element: character.element || 0,
|
||||||
|
race1: character.race?.[0] ?? null,
|
||||||
|
race2: character.race?.[1] ?? null,
|
||||||
|
gender: character.gender || 0,
|
||||||
|
proficiency1: character.proficiency?.[0] || 0,
|
||||||
|
proficiency2: character.proficiency?.[1] || 0,
|
||||||
|
minHp: character.hp?.minHp || 0,
|
||||||
|
maxHp: character.hp?.maxHp || 0,
|
||||||
|
maxHpFlb: character.hp?.maxHpFlb || 0,
|
||||||
|
minAtk: character.atk?.minAtk || 0,
|
||||||
|
maxAtk: character.atk?.maxAtk || 0,
|
||||||
|
maxAtkFlb: character.atk?.maxAtkFlb || 0,
|
||||||
|
flb: character.uncap?.flb || false,
|
||||||
|
ulb: character.uncap?.ulb || false,
|
||||||
|
transcendence: character.uncap?.transcendence || false,
|
||||||
|
special: character.special || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Options for select dropdowns - using centralized utilities
|
||||||
|
const rarityOptions = getRarityOptions()
|
||||||
|
const elementOptions = getElementOptions()
|
||||||
|
const raceOptions = getRaceOptions()
|
||||||
|
const genderOptions = getGenderOptions()
|
||||||
|
const proficiencyOptions = getProficiencyOptions()
|
||||||
|
|
||||||
|
function toggleEditMode() {
|
||||||
|
editMode = !editMode
|
||||||
|
saveError = null
|
||||||
|
saveSuccess = false
|
||||||
|
|
||||||
|
// Reset data when canceling
|
||||||
|
if (!editMode && character) {
|
||||||
|
editData = {
|
||||||
|
name: character.name || '',
|
||||||
|
granblueId: character.granblueId || '',
|
||||||
|
characterId: character.characterId ?? null,
|
||||||
|
rarity: character.rarity || 1,
|
||||||
|
element: character.element || 0,
|
||||||
|
race1: character.race?.[0] ?? null,
|
||||||
|
race2: character.race?.[1] ?? null,
|
||||||
|
gender: character.gender || 0,
|
||||||
|
proficiency1: character.proficiency?.[0] || 0,
|
||||||
|
proficiency2: character.proficiency?.[1] || 0,
|
||||||
|
minHp: character.hp?.minHp || 0,
|
||||||
|
maxHp: character.hp?.maxHp || 0,
|
||||||
|
maxHpFlb: character.hp?.maxHpFlb || 0,
|
||||||
|
minAtk: character.atk?.minAtk || 0,
|
||||||
|
maxAtk: character.atk?.maxAtk || 0,
|
||||||
|
maxAtkFlb: character.atk?.maxAtkFlb || 0,
|
||||||
|
flb: character.uncap?.flb || false,
|
||||||
|
ulb: character.uncap?.ulb || false,
|
||||||
|
transcendence: character.uncap?.transcendence || false,
|
||||||
|
special: character.special || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
isSaving = true
|
||||||
|
saveError = null
|
||||||
|
saveSuccess = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare the data for API
|
||||||
|
const payload = {
|
||||||
|
name: editData.name,
|
||||||
|
granblue_id: editData.granblueId,
|
||||||
|
character_id: editData.characterId,
|
||||||
|
rarity: editData.rarity,
|
||||||
|
element: editData.element,
|
||||||
|
race: [editData.race1, editData.race2].filter((r) => r !== null && r !== undefined),
|
||||||
|
gender: editData.gender,
|
||||||
|
proficiency: [editData.proficiency1, editData.proficiency2],
|
||||||
|
hp: {
|
||||||
|
min_hp: editData.minHp,
|
||||||
|
max_hp: editData.maxHp,
|
||||||
|
max_hp_flb: editData.maxHpFlb
|
||||||
|
},
|
||||||
|
atk: {
|
||||||
|
min_atk: editData.minAtk,
|
||||||
|
max_atk: editData.maxAtk,
|
||||||
|
max_atk_flb: editData.maxAtkFlb
|
||||||
|
},
|
||||||
|
uncap: {
|
||||||
|
flb: editData.flb,
|
||||||
|
ulb: editData.ulb,
|
||||||
|
transcendence: editData.transcendence
|
||||||
|
},
|
||||||
|
special: editData.special
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: When backend endpoint is ready, make the API call here
|
||||||
|
// const response = await fetch(`/api/v1/characters/${character.id}`, {
|
||||||
|
// method: 'PUT',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify(payload)
|
||||||
|
// })
|
||||||
|
|
||||||
|
// For now, just simulate success
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
saveSuccess = true
|
||||||
|
editMode = false
|
||||||
|
|
||||||
|
// Show success message for 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
saveSuccess = false
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
saveError = 'Failed to save changes. Please try again.'
|
||||||
|
console.error('Save error:', error)
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get character image
|
||||||
|
// Helper function for character grid image
|
||||||
|
function getCharacterGridImage(character: any): string {
|
||||||
|
return getCharacterImage(character?.granblueId, 'grid', '01')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate uncap properties for the indicator
|
||||||
|
const uncap = $derived(
|
||||||
|
editMode
|
||||||
|
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
|
||||||
|
: (character?.uncap ?? { flb: false, ulb: false, transcendence: false })
|
||||||
|
)
|
||||||
|
const flb = $derived(uncap.flb ?? false)
|
||||||
|
const ulb = $derived(uncap.ulb ?? false)
|
||||||
|
const transcendence = $derived(uncap.transcendence ?? false)
|
||||||
|
const special = $derived(editMode ? editData.special : (character?.special ?? false))
|
||||||
|
|
||||||
|
const uncapLevel = $derived(
|
||||||
|
getCharacterMaxUncapLevel({ special, uncap: { flb, ulb, transcendence } })
|
||||||
|
)
|
||||||
|
const transcendenceStage = $derived(transcendence ? 5 : 0)
|
||||||
|
|
||||||
|
// Get element name for checkbox theming
|
||||||
|
const elementName = $derived.by(() => {
|
||||||
|
const el = editMode ? editData.element : character?.element
|
||||||
|
const label = getElementLabel(el)
|
||||||
|
return label !== '—' && label !== 'Null'
|
||||||
|
? (label.toLowerCase() as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light')
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if character}
|
||||||
|
<div class="content">
|
||||||
|
<DetailsHeader
|
||||||
|
type="character"
|
||||||
|
item={character}
|
||||||
|
image={getCharacterGridImage(character)}
|
||||||
|
onEdit={toggleEditMode}
|
||||||
|
showEdit={canEdit}
|
||||||
|
{editMode}
|
||||||
|
onSave={saveChanges}
|
||||||
|
onCancel={toggleEditMode}
|
||||||
|
{isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if saveSuccess || saveError}
|
||||||
|
<div class="edit-controls">
|
||||||
|
{#if saveSuccess}
|
||||||
|
<span class="success-message">Changes saved successfully!</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if saveError}
|
||||||
|
<span class="error-message">{saveError}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="details">
|
||||||
|
<DetailsContainer title="Metadata">
|
||||||
|
{#if editMode}
|
||||||
|
<DetailItem
|
||||||
|
label="Rarity"
|
||||||
|
bind:value={editData.rarity}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={rarityOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Granblue ID"
|
||||||
|
bind:value={editData.granblueId}
|
||||||
|
editable={true}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Character ID"
|
||||||
|
bind:value={editData.characterId}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
|
||||||
|
<DetailItem label="Granblue ID" value={character.granblueId} />
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Details">
|
||||||
|
{#if character.uncap}
|
||||||
|
<DetailItem label="Uncap">
|
||||||
|
<UncapIndicator
|
||||||
|
type="character"
|
||||||
|
{uncapLevel}
|
||||||
|
{transcendenceStage}
|
||||||
|
{flb}
|
||||||
|
{ulb}
|
||||||
|
{transcendence}
|
||||||
|
{special}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</DetailItem>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editMode}
|
||||||
|
<DetailItem
|
||||||
|
label="FLB"
|
||||||
|
bind:value={editData.flb}
|
||||||
|
editable={true}
|
||||||
|
type="checkbox"
|
||||||
|
element={elementName}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="ULB"
|
||||||
|
bind:value={editData.ulb}
|
||||||
|
editable={true}
|
||||||
|
type="checkbox"
|
||||||
|
element={elementName}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Transcendence"
|
||||||
|
bind:value={editData.transcendence}
|
||||||
|
editable={true}
|
||||||
|
type="checkbox"
|
||||||
|
element={elementName}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Special"
|
||||||
|
bind:value={editData.special}
|
||||||
|
editable={true}
|
||||||
|
type="checkbox"
|
||||||
|
element={elementName}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editMode}
|
||||||
|
<DetailItem
|
||||||
|
label="Element"
|
||||||
|
bind:value={editData.element}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={elementOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Race 1"
|
||||||
|
bind:value={editData.race1}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={raceOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Race 2"
|
||||||
|
bind:value={editData.race2}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={raceOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Gender"
|
||||||
|
bind:value={editData.gender}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={genderOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Proficiency 1"
|
||||||
|
bind:value={editData.proficiency1}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={proficiencyOptions}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Proficiency 2"
|
||||||
|
bind:value={editData.proficiency2}
|
||||||
|
editable={true}
|
||||||
|
type="select"
|
||||||
|
options={proficiencyOptions}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<DetailItem label="Element" value={getElementLabel(character.element)} />
|
||||||
|
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} />
|
||||||
|
{#if character.race?.[1]}
|
||||||
|
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
|
||||||
|
{/if}
|
||||||
|
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
|
||||||
|
<DetailItem
|
||||||
|
label="Proficiency 1"
|
||||||
|
value={getProficiencyLabel(character.proficiency?.[0] ?? 0)}
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Proficiency 2"
|
||||||
|
value={getProficiencyLabel(character.proficiency?.[1] ?? 0)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="HP Stats">
|
||||||
|
{#if editMode}
|
||||||
|
<DetailItem
|
||||||
|
label="Base HP"
|
||||||
|
bind:value={editData.minHp}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Max HP"
|
||||||
|
bind:value={editData.maxHp}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Max HP (FLB)"
|
||||||
|
bind:value={editData.maxHpFlb}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<DetailItem label="Base HP" value={character.hp?.minHp} />
|
||||||
|
<DetailItem label="Max HP" value={character.hp?.maxHp} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Attack Stats">
|
||||||
|
{#if editMode}
|
||||||
|
<DetailItem
|
||||||
|
label="Base Attack"
|
||||||
|
bind:value={editData.minAtk}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Max Attack"
|
||||||
|
bind:value={editData.maxAtk}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<DetailItem
|
||||||
|
label="Max Attack (FLB)"
|
||||||
|
bind:value={editData.maxAtkFlb}
|
||||||
|
editable={true}
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<DetailItem label="Base Attack" value={character.atk?.minAtk} />
|
||||||
|
<DetailItem label="Max Attack" value={character.atk?.maxAtk} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
{#if !editMode && relatedQuery.data?.length}
|
||||||
|
<DetailsContainer title="Related Units">
|
||||||
|
<div class="related-units">
|
||||||
|
{#each relatedQuery.data as related}
|
||||||
|
<a href="/database/characters/{related.id}" class="related-unit">
|
||||||
|
<img
|
||||||
|
src={getCharacterImage(related.granblueId, 'grid', '01')}
|
||||||
|
alt={related.name.en}
|
||||||
|
class="related-image"
|
||||||
|
/>
|
||||||
|
<span class="related-name">{related.name.en}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</DetailsContainer>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="not-found">
|
||||||
|
<h2>Character Not Found</h2>
|
||||||
|
<p>The character you're looking for could not be found.</p>
|
||||||
|
<button onclick={() => goto('/database/characters')}>Back to Characters</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit * 4;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: spacing.$unit * 0.5 spacing.$unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: spacing.$unit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: white;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-controls {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
border-bottom: 1px solid colors.$grey-80;
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: colors.$grey-30;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
animation: fadeIn effects.$duration-opacity-fade ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: colors.$error;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
animation: fadeIn effects.$duration-opacity-fade ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-units {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-unit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: colors.$grey-30;
|
||||||
|
|
||||||
|
&:hover .related-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-image {
|
||||||
|
width: 128px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-name {
|
||||||
|
margin-top: spacing.$unit;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
src/routes/(app)/database/summons/+page.server.ts
Normal file
16
src/routes/(app)/database/summons/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// Enforce authorization at individual page level
|
||||||
|
if (!locals.session.isAuthenticated) {
|
||||||
|
throw redirect(302, '/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
91
src/routes/(app)/database/summons/+page.svelte
Normal file
91
src/routes/(app)/database/summons/+page.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import SummonImageCell from '$lib/components/database/cells/SummonImageCell.svelte'
|
||||||
|
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
|
||||||
|
import SummonUncapCell from '$lib/components/database/cells/SummonUncapCell.svelte'
|
||||||
|
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
|
||||||
|
import { getRarityLabel } from '$lib/utils/rarity'
|
||||||
|
|
||||||
|
// Column configuration for summons
|
||||||
|
const columns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblueId',
|
||||||
|
header: 'Image',
|
||||||
|
width: 80,
|
||||||
|
cell: SummonImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj: any) => {
|
||||||
|
// nameObj is the name property itself, not the full item
|
||||||
|
if (!nameObj) return '—'
|
||||||
|
if (typeof nameObj === 'string') return nameObj
|
||||||
|
// Handle {en: "...", ja: "..."} structure
|
||||||
|
return nameObj.en || nameObj.ja || '—'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rarity',
|
||||||
|
header: 'Rarity',
|
||||||
|
width: 80,
|
||||||
|
sort: true,
|
||||||
|
template: (rarity: any) => getRarityLabel(rarity)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
cell: ElementCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uncap',
|
||||||
|
header: 'Uncap',
|
||||||
|
width: 160,
|
||||||
|
cell: SummonUncapCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_updated',
|
||||||
|
header: 'Last Updated',
|
||||||
|
width: 120,
|
||||||
|
sort: true,
|
||||||
|
cell: LastUpdatedCell
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-page">
|
||||||
|
<DatabaseGridWithProvider resource="summons" {columns} pageSize={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-page {
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/(app)/database/summons/[id]/+page.server.ts
Normal file
29
src/routes/(app)/database/summons/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||||
|
import { error } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
|
try {
|
||||||
|
// Get parent data to access role
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
const summon = await entityAdapter.getSummon(params.id)
|
||||||
|
|
||||||
|
if (!summon) {
|
||||||
|
throw error(404, 'Summon not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summon,
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load summon:', err)
|
||||||
|
|
||||||
|
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||||
|
throw error(404, 'Summon not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, 'Failed to load summon')
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/routes/(app)/database/summons/[id]/+page.svelte
Normal file
267
src/routes/(app)/database/summons/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
import { getRarityLabel } from '$lib/utils/rarity'
|
||||||
|
import { getElementLabel, getElementIcon } from '$lib/utils/element'
|
||||||
|
import { getSummonImage } from '$lib/utils/images'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||||
|
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Use TanStack Query with SSR initial data
|
||||||
|
const summonQuery = createQuery(() => ({
|
||||||
|
...entityQueries.summon(data.summon?.id ?? ''),
|
||||||
|
...withInitialData(data.summon)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get summon from query
|
||||||
|
const summon = $derived(summonQuery.data)
|
||||||
|
|
||||||
|
// Helper function to get summon grid image
|
||||||
|
function getSummonGridImage(summon: any): string {
|
||||||
|
return getSummonImage(summon?.granblueId, 'grid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate uncap properties for the indicator
|
||||||
|
const uncap = $derived(summon?.uncap ?? {})
|
||||||
|
const flb = $derived(uncap.flb ?? false)
|
||||||
|
const ulb = $derived(uncap.ulb ?? false)
|
||||||
|
const transcendence = $derived(uncap.transcendence ?? false)
|
||||||
|
|
||||||
|
// Calculate maximum uncap level based on available uncaps
|
||||||
|
// Summons: 3 base + FLB + ULB + transcendence
|
||||||
|
const getMaxUncapLevel = () => {
|
||||||
|
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncapLevel = $derived(getMaxUncapLevel())
|
||||||
|
// For details view, show maximum transcendence stage when available
|
||||||
|
const transcendenceStage = $derived(transcendence ? 5 : 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="summon-detail">
|
||||||
|
{#if summon}
|
||||||
|
<div class="summon-content">
|
||||||
|
<DetailsHeader type="summon" item={summon} image={getSummonGridImage(summon)} />
|
||||||
|
|
||||||
|
<DetailsContainer title="HP Stats">
|
||||||
|
<DetailItem label="Base HP" value={summon.hp?.minHp} />
|
||||||
|
<DetailItem label="Max HP" value={summon.hp?.maxHp} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max HP (FLB)" value={summon.hp?.maxHpFlb} />
|
||||||
|
{/if}
|
||||||
|
{#if ulb}
|
||||||
|
<DetailItem label="Max HP (ULB)" value={summon.hp?.maxHpUlb} />
|
||||||
|
{/if}
|
||||||
|
{#if transcendence}
|
||||||
|
<DetailItem label="Max HP (XLB)" value={summon.hp?.maxHpXlb} />
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Attack Stats">
|
||||||
|
<DetailItem label="Base Attack" value={summon.atk?.minAtk} />
|
||||||
|
<DetailItem label="Max Attack" value={summon.atk?.maxAtk} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max Attack (FLB)" value={summon.atk?.maxAtkFlb} />
|
||||||
|
{/if}
|
||||||
|
{#if ulb}
|
||||||
|
<DetailItem label="Max Attack (ULB)" value={summon.atk?.maxAtkUlb} />
|
||||||
|
{/if}
|
||||||
|
{#if transcendence}
|
||||||
|
<DetailItem label="Max Attack (XLB)" value={summon.atk?.maxAtkXlb} />
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Details">
|
||||||
|
<DetailItem label="Series" value={summon.series} />
|
||||||
|
{#if summon.uncap}
|
||||||
|
<DetailItem label="Uncap">
|
||||||
|
<UncapIndicator
|
||||||
|
type="summon"
|
||||||
|
{uncapLevel}
|
||||||
|
{transcendenceStage}
|
||||||
|
{flb}
|
||||||
|
{ulb}
|
||||||
|
{transcendence}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</DetailItem>
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<div class="summon-abilities">
|
||||||
|
<h3>Call Effect</h3>
|
||||||
|
<div class="abilities-section">
|
||||||
|
{#if summon.callName || summon.callDescription}
|
||||||
|
<div class="ability-item">
|
||||||
|
<h4 class="ability-name">{summon.callName || 'Call Effect'}</h4>
|
||||||
|
<p class="ability-description">
|
||||||
|
{summon.callDescription || 'No description available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="no-abilities">No call effect information available</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Aura Effect</h3>
|
||||||
|
<div class="abilities-section">
|
||||||
|
{#if summon.auraName || summon.auraDescription}
|
||||||
|
<div class="ability-item">
|
||||||
|
<h4 class="ability-name">{summon.auraName || 'Aura Effect'}</h4>
|
||||||
|
<p class="ability-description">
|
||||||
|
{summon.auraDescription || 'No description available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="no-abilities">No aura effect information available</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if summon.subAuraName || summon.subAuraDescription}
|
||||||
|
<h3>Sub Aura Effect</h3>
|
||||||
|
<div class="abilities-section">
|
||||||
|
<div class="ability-item">
|
||||||
|
<h4 class="ability-name">{summon.subAuraName || 'Sub Aura Effect'}</h4>
|
||||||
|
<p class="ability-description">
|
||||||
|
{summon.subAuraDescription || 'No description available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="not-found">
|
||||||
|
<h2>Summon Not Found</h2>
|
||||||
|
<p>The summon you're looking for could not be found.</p>
|
||||||
|
<button onclick={() => goto('/database/summons')}>Back to Summons</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.summon-detail {
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: spacing.$unit-half spacing.$unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
margin-bottom: spacing.$unit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit * 4;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: spacing.$unit-half spacing.$unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: spacing.$unit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summon-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summon-abilities {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: typography.$font-large;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0 0 spacing.$unit 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abilities-section {
|
||||||
|
margin-bottom: spacing.$unit * 2;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-item {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.ability-name {
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
margin: 0 0 spacing.$unit * 0.5 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ability-description {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-abilities {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
src/routes/(app)/database/weapons/+page.server.ts
Normal file
16
src/routes/(app)/database/weapons/+page.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// Enforce authorization at individual page level
|
||||||
|
if (!locals.session.isAuthenticated) {
|
||||||
|
throw redirect(302, '/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
99
src/routes/(app)/database/weapons/+page.svelte
Normal file
99
src/routes/(app)/database/weapons/+page.svelte
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import DatabaseGridWithProvider from '$lib/components/database/DatabaseGridWithProvider.svelte'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import WeaponImageCell from '$lib/components/database/cells/WeaponImageCell.svelte'
|
||||||
|
import ElementCell from '$lib/components/database/cells/ElementCell.svelte'
|
||||||
|
import ProficiencyCell from '$lib/components/database/cells/ProficiencyCell.svelte'
|
||||||
|
import WeaponUncapCell from '$lib/components/database/cells/WeaponUncapCell.svelte'
|
||||||
|
import LastUpdatedCell from '$lib/components/database/cells/LastUpdatedCell.svelte'
|
||||||
|
import { getRarityLabel } from '$lib/utils/rarity'
|
||||||
|
|
||||||
|
// Column configuration for weapons
|
||||||
|
const columns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblueId',
|
||||||
|
header: 'Image',
|
||||||
|
width: 80,
|
||||||
|
cell: WeaponImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj: any) => {
|
||||||
|
// nameObj is the name property itself, not the full item
|
||||||
|
if (!nameObj) return '—'
|
||||||
|
if (typeof nameObj === 'string') return nameObj
|
||||||
|
// Handle {en: "...", ja: "..."} structure
|
||||||
|
return nameObj.en || nameObj.ja || '—'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rarity',
|
||||||
|
header: 'Rarity',
|
||||||
|
width: 80,
|
||||||
|
sort: true,
|
||||||
|
template: (rarity: any) => getRarityLabel(rarity)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
cell: ElementCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proficiency',
|
||||||
|
header: 'Proficiency',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
cell: ProficiencyCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uncap',
|
||||||
|
header: 'Uncap',
|
||||||
|
width: 160,
|
||||||
|
cell: WeaponUncapCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_updated',
|
||||||
|
header: 'Last Updated',
|
||||||
|
width: 120,
|
||||||
|
sort: true,
|
||||||
|
cell: LastUpdatedCell
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-page">
|
||||||
|
<DatabaseGridWithProvider resource="weapons" {columns} pageSize={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-page {
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/(app)/database/weapons/[id]/+page.server.ts
Normal file
29
src/routes/(app)/database/weapons/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||||
|
import { error } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
|
try {
|
||||||
|
// Get parent data to access role
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
const weapon = await entityAdapter.getWeapon(params.id)
|
||||||
|
|
||||||
|
if (!weapon) {
|
||||||
|
throw error(404, 'Weapon not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weapon,
|
||||||
|
role: parentData.role
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load weapon:', err)
|
||||||
|
|
||||||
|
if (err instanceof Error && 'status' in err && err.status === 404) {
|
||||||
|
throw error(404, 'Weapon not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, 'Failed to load weapon')
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/routes/(app)/database/weapons/[id]/+page.svelte
Normal file
241
src/routes/(app)/database/weapons/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { entityQueries } from '$lib/api/queries/entity.queries'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
import { getRarityLabel } from '$lib/utils/rarity'
|
||||||
|
import { getElementLabel, getElementIcon } from '$lib/utils/element'
|
||||||
|
import { getProficiencyLabel, getProficiencyIcon } from '$lib/utils/proficiency'
|
||||||
|
import { getWeaponGridImage } from '$lib/utils/images'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
|
||||||
|
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||||
|
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||||
|
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Use TanStack Query with SSR initial data
|
||||||
|
const weaponQuery = createQuery(() => ({
|
||||||
|
...entityQueries.weapon(data.weapon?.id ?? ''),
|
||||||
|
...withInitialData(data.weapon)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get weapon from query
|
||||||
|
const weapon = $derived(weaponQuery.data)
|
||||||
|
|
||||||
|
// Helper function to get weapon grid image
|
||||||
|
function getWeaponImage(weapon: any): string {
|
||||||
|
return getWeaponGridImage(weapon?.granblueId, weapon?.element, weapon?.instanceElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate uncap properties for the indicator
|
||||||
|
const uncap = $derived(weapon?.uncap ?? {})
|
||||||
|
const flb = $derived(uncap.flb ?? false)
|
||||||
|
const ulb = $derived(uncap.ulb ?? false)
|
||||||
|
const transcendence = $derived(uncap.transcendence ?? false)
|
||||||
|
|
||||||
|
// Calculate maximum uncap level based on available uncaps
|
||||||
|
// Weapons: 3 base + FLB + ULB + transcendence
|
||||||
|
const getMaxUncapLevel = () => {
|
||||||
|
return transcendence ? 6 : ulb ? 5 : flb ? 4 : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncapLevel = $derived(getMaxUncapLevel())
|
||||||
|
// For details view, show maximum transcendence stage when available
|
||||||
|
const transcendenceStage = $derived(transcendence ? 5 : 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="weapon-detail">
|
||||||
|
{#if weapon}
|
||||||
|
<div class="weapon-content">
|
||||||
|
<DetailsHeader type="weapon" item={weapon} image={getWeaponImage(weapon)} />
|
||||||
|
|
||||||
|
<DetailsContainer title="Level & Skill">
|
||||||
|
<DetailItem label="Max Level" value={weapon.maxLevel} />
|
||||||
|
<DetailItem label="Max Skill Level" value={weapon.skillLevelCap} />
|
||||||
|
{#if weapon.uncap}
|
||||||
|
<DetailItem label="Uncap">
|
||||||
|
<UncapIndicator
|
||||||
|
type="weapon"
|
||||||
|
{uncapLevel}
|
||||||
|
{transcendenceStage}
|
||||||
|
{flb}
|
||||||
|
{ulb}
|
||||||
|
{transcendence}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</DetailItem>
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="HP Stats">
|
||||||
|
<DetailItem label="Base HP" value={weapon.hp?.minHp} />
|
||||||
|
<DetailItem label="Max HP" value={weapon.hp?.maxHp} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max HP (FLB)" value={weapon.hp?.maxHpFlb} />
|
||||||
|
{/if}
|
||||||
|
{#if ulb}
|
||||||
|
<DetailItem label="Max HP (ULB)" value={weapon.hp?.maxHpUlb} />
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<DetailsContainer title="Attack Stats">
|
||||||
|
<DetailItem label="Base Attack" value={weapon.atk?.minAtk} />
|
||||||
|
<DetailItem label="Max Attack" value={weapon.atk?.maxAtk} />
|
||||||
|
{#if flb}
|
||||||
|
<DetailItem label="Max Attack (FLB)" value={weapon.atk?.maxAtkFlb} />
|
||||||
|
{/if}
|
||||||
|
{#if ulb}
|
||||||
|
<DetailItem label="Max Attack (ULB)" value={weapon.atk?.maxAtkUlb} />
|
||||||
|
{/if}
|
||||||
|
</DetailsContainer>
|
||||||
|
|
||||||
|
<div class="weapon-skills">
|
||||||
|
<h3>Skills</h3>
|
||||||
|
<div class="skills-grid">
|
||||||
|
{#if weapon.weapon_skills && weapon.weapon_skills.length > 0}
|
||||||
|
{#each weapon.weapon_skills as skill}
|
||||||
|
<div class="skill-item">
|
||||||
|
<h4 class="skill-name">{skill.name || 'Unknown Skill'}</h4>
|
||||||
|
<p class="skill-description">{skill.description || 'No description available'}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="no-skills">No skills available</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="not-found">
|
||||||
|
<h2>Weapon Not Found</h2>
|
||||||
|
<p>The weapon you're looking for could not be found.</p>
|
||||||
|
<button onclick={() => goto('/database/weapons')}>Back to Weapons</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.weapon-detail {
|
||||||
|
padding: spacing.$unit-2x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit-2x;
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: spacing.$unit * 0.5 spacing.$unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
margin-bottom: spacing.$unit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit * 4;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: spacing.$unit * 0.5 spacing.$unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: spacing.$unit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weapon-skills {
|
||||||
|
padding: spacing.$unit * 2;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: typography.$font-large;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin: 0 0 spacing.$unit 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
.skill-item {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
margin: 0 0 spacing.$unit * 0.5 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-description {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-skills {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.skills-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
src/routes/(app)/guides/+page.svelte
Normal file
31
src/routes/(app)/guides/+page.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Guides</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Guides</h1>
|
||||||
|
<p>Guides and resources for Granblue Fantasy players.</p>
|
||||||
|
<!-- Content will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/routes/(app)/me/+page.server.ts
Normal file
9
src/routes/(app)/me/+page.server.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const username = locals.session?.account?.username
|
||||||
|
if (!username) throw redirect(302, '/auth/login')
|
||||||
|
throw redirect(302, `/${encodeURIComponent(username)}`)
|
||||||
|
}
|
||||||
|
|
||||||
31
src/routes/(app)/me/+page.svelte
Normal file
31
src/routes/(app)/me/+page.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { AccountCookie } from '$lib/types/AccountCookie'
|
||||||
|
import type { UserCookie } from '$lib/types/UserCookie'
|
||||||
|
|
||||||
|
export let data: { account: AccountCookie; user: UserCookie }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Welcome, {data.account.username}!</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Account</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>User ID:</strong> {data.account.userId}</li>
|
||||||
|
<li><strong>Role:</strong> {data.account.role}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Preferences</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Language:</strong> {data.user.language}</li>
|
||||||
|
<li><strong>Theme:</strong> {data.user.theme}</li>
|
||||||
|
<li><strong>Gender:</strong> {data.user.gender}</li>
|
||||||
|
<li><strong>Element:</strong> {data.user.element}</li>
|
||||||
|
<li><strong>Picture:</strong> {data.user.picture}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button>Log out</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
18
src/routes/(app)/settings/+page.server.ts
Normal file
18
src/routes/(app)/settings/+page.server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { getAccountFromCookies, getUserFromCookies } from '$lib/auth/cookies'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
|
const account = getAccountFromCookies(cookies)
|
||||||
|
const currentUser = getUserFromCookies(cookies)
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!account || !currentUser) {
|
||||||
|
throw redirect(303, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
currentUser
|
||||||
|
}
|
||||||
|
}
|
||||||
366
src/routes/(app)/settings/+page.svelte
Normal file
366
src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import Select from '$lib/components/ui/Select.svelte'
|
||||||
|
import Switch from '$lib/components/ui/switch/Switch.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import { pictureData } from '$lib/utils/pictureData'
|
||||||
|
import { users } from '$lib/api/resources/users'
|
||||||
|
import type { UserCookie } from '$lib/types/UserCookie'
|
||||||
|
import { invalidateAll } from '$app/navigation'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!data.account || !data.currentUser) {
|
||||||
|
goto('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = data.account!
|
||||||
|
const user = data.currentUser!
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let picture = $state(user.picture)
|
||||||
|
let gender = $state(user.gender)
|
||||||
|
let language = $state(user.language)
|
||||||
|
let theme = $state(user.theme)
|
||||||
|
let bahamut = $state(user.bahamut ?? false)
|
||||||
|
|
||||||
|
let saving = $state(false)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
let success = $state(false)
|
||||||
|
|
||||||
|
// Get current locale from user settings
|
||||||
|
const locale = $derived(user.language as 'en' | 'ja')
|
||||||
|
|
||||||
|
// Prepare options for selects
|
||||||
|
const pictureOptions = $derived(
|
||||||
|
pictureData
|
||||||
|
.sort((a, b) => a.name.en.localeCompare(b.name.en))
|
||||||
|
.map((p) => ({
|
||||||
|
value: p.filename,
|
||||||
|
label: p.name[locale] || p.name.en,
|
||||||
|
image: `/profile/${p.filename}.png`
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ value: 0, label: 'Gran' },
|
||||||
|
{ value: 1, label: 'Djeeta' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'ja', label: '日本語' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: 'system', label: 'System' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'dark', label: 'Dark' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Get current picture data
|
||||||
|
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleSave(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
error = null
|
||||||
|
success = false
|
||||||
|
saving = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare the update data
|
||||||
|
const updateData = {
|
||||||
|
picture,
|
||||||
|
element: currentPicture?.element,
|
||||||
|
gender,
|
||||||
|
language,
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call API to update user settings
|
||||||
|
const response = await users.update(account.userId, updateData)
|
||||||
|
|
||||||
|
// Update the user cookie
|
||||||
|
const updatedUser: UserCookie = {
|
||||||
|
picture: response.avatar.picture,
|
||||||
|
element: response.avatar.element,
|
||||||
|
language: response.language,
|
||||||
|
gender: response.gender,
|
||||||
|
theme: response.theme,
|
||||||
|
bahamut
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a request to update the cookie server-side
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updatedUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
success = true
|
||||||
|
|
||||||
|
// If language or theme changed, we need to reload
|
||||||
|
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) {
|
||||||
|
setTimeout(() => {
|
||||||
|
invalidateAll()
|
||||||
|
window.location.reload()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update settings:', err)
|
||||||
|
error = 'Failed to update settings. Please try again.'
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="settings-page">
|
||||||
|
<div class="settings-container">
|
||||||
|
<h1>Account Settings</h1>
|
||||||
|
<p class="username">@{account.username}</p>
|
||||||
|
|
||||||
|
<form onsubmit={handleSave} class="settings-form">
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<div class="success-message">Settings saved successfully!</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-fields">
|
||||||
|
<!-- Picture Selection with Preview -->
|
||||||
|
<div class="picture-section">
|
||||||
|
<label>Avatar</label>
|
||||||
|
<div class="picture-content">
|
||||||
|
<div class="current-avatar">
|
||||||
|
<img
|
||||||
|
src={`/profile/${picture}.png`}
|
||||||
|
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
|
||||||
|
alt={currentPicture?.name[locale] || ''}
|
||||||
|
class="avatar-preview element-{currentPicture?.element}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
bind:value={picture}
|
||||||
|
options={pictureOptions}
|
||||||
|
placeholder="Select an avatar"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gender Selection -->
|
||||||
|
<div class="form-field">
|
||||||
|
<Select
|
||||||
|
bind:value={gender}
|
||||||
|
options={genderOptions}
|
||||||
|
label="Gender"
|
||||||
|
placeholder="Select gender"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Language Selection -->
|
||||||
|
<div class="form-field">
|
||||||
|
<Select
|
||||||
|
bind:value={language}
|
||||||
|
options={languageOptions}
|
||||||
|
label="Language"
|
||||||
|
placeholder="Select language"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Selection -->
|
||||||
|
<div class="form-field">
|
||||||
|
<Select
|
||||||
|
bind:value={theme}
|
||||||
|
options={themeOptions}
|
||||||
|
label="Theme"
|
||||||
|
placeholder="Select theme"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Mode (only for admins) -->
|
||||||
|
{#if account.role === 9}
|
||||||
|
<div class="switch-field">
|
||||||
|
<label for="bahamut-mode">
|
||||||
|
<span>Admin Mode</span>
|
||||||
|
<Switch bind:checked={bahamut} name="bahamut-mode" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
href="/me"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
padding: spacing.$unit-3x;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: effects.$page-border;
|
||||||
|
border-radius: layout.$page-corner;
|
||||||
|
padding: spacing.$unit-4x;
|
||||||
|
box-shadow: effects.$page-elevation;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xlarge;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: spacing.$unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
margin-bottom: spacing.$unit-4x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: rgba(colors.$error, 0.1);
|
||||||
|
border: 1px solid colors.$error;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
color: colors.$error;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
background-color: rgba(colors.$yellow, 0.1);
|
||||||
|
border: 1px solid colors.$yellow;
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
color: colors.$yellow;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture-content {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-3x;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.current-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: layout.$full-corner;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
background-color: var(--placeholder-bg);
|
||||||
|
|
||||||
|
&.element-fire {
|
||||||
|
background-color: colors.$fire-bg-20;
|
||||||
|
}
|
||||||
|
&.element-water {
|
||||||
|
background-color: colors.$water-bg-20;
|
||||||
|
}
|
||||||
|
&.element-earth {
|
||||||
|
background-color: colors.$earth-bg-20;
|
||||||
|
}
|
||||||
|
&.element-wind {
|
||||||
|
background-color: colors.$wind-bg-20;
|
||||||
|
}
|
||||||
|
&.element-light {
|
||||||
|
background-color: colors.$light-bg-20;
|
||||||
|
}
|
||||||
|
&.element-dark {
|
||||||
|
background-color: colors.$dark-bg-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-field {
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-radius: layout.$card-corner;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: spacing.$unit-2x;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
28
src/routes/(app)/teams/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const authUserId = locals.session?.account?.userId
|
||||||
|
|
||||||
|
let partyFound = false
|
||||||
|
let party = null
|
||||||
|
let canEdit = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the party using adapter
|
||||||
|
party = await partyAdapter.getByShortcode(params.id)
|
||||||
|
partyFound = true
|
||||||
|
|
||||||
|
// Determine if user can edit
|
||||||
|
canEdit = authUserId ? party.user?.id === authUserId : false
|
||||||
|
} catch (err) {
|
||||||
|
// Error is expected for test/invalid IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
party: party ? structuredClone(party) : null,
|
||||||
|
canEdit: Boolean(canEdit),
|
||||||
|
partyFound,
|
||||||
|
authUserId: authUserId || null
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/routes/(app)/teams/[id]/+page.svelte
Normal file
42
src/routes/(app)/teams/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import Party from '$lib/components/party/Party.svelte'
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TanStack Query v6 SSR Integration Example
|
||||||
|
*
|
||||||
|
* This demonstrates the `withInitialData` pattern for pages using +page.server.ts.
|
||||||
|
* The server-fetched party data is used as initial data for the query, which means:
|
||||||
|
*
|
||||||
|
* 1. No loading state on initial render (data is already available)
|
||||||
|
* 2. The query can refetch in the background when data becomes stale
|
||||||
|
* 3. The data is cached and shared across components using the same query key
|
||||||
|
*
|
||||||
|
* Note: The Party component currently manages its own state, so we pass the
|
||||||
|
* server data directly. In a future refactor, the Party component could use
|
||||||
|
* this query directly for automatic cache updates and background refetching.
|
||||||
|
*/
|
||||||
|
const partyQuery = createQuery(() => ({
|
||||||
|
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
|
||||||
|
...withInitialData(data.party),
|
||||||
|
enabled: !!data.party?.shortcode
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Use query data if available, fall back to server data
|
||||||
|
// This allows the query to refetch and update the UI automatically
|
||||||
|
const party = $derived(partyQuery.data ?? data.party)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if party}
|
||||||
|
<Party party={party} canEdit={data.canEdit || false} authUserId={data.authUserId} />
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<h1>Party not found</h1>
|
||||||
|
<p>No party data available for this code.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
34
src/routes/(app)/teams/explore/+page.server.ts
Normal file
34
src/routes/(app)/teams/explore/+page.server.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { PageServerLoad } from './$types'
|
||||||
|
import { error } from '@sveltejs/kit'
|
||||||
|
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, depends }) => {
|
||||||
|
depends('app:parties:list')
|
||||||
|
|
||||||
|
const pageParam = url.searchParams.get('page')
|
||||||
|
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await partyAdapter.list({ page })
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: response.results,
|
||||||
|
page,
|
||||||
|
total: response.total,
|
||||||
|
totalPages: response.totalPages,
|
||||||
|
perPage: response.perPage || 20
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[explore/+page.server.ts] Failed to load teams:', {
|
||||||
|
error: e,
|
||||||
|
message: e?.message,
|
||||||
|
status: e?.status,
|
||||||
|
stack: e?.stack,
|
||||||
|
details: e?.details
|
||||||
|
})
|
||||||
|
const errorMessage = `Failed to load teams: ${e?.message || 'Unknown error'}. Status: ${e?.status || 'unknown'}`
|
||||||
|
throw error(e?.status || 502, errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
168
src/routes/(app)/teams/explore/+page.svelte
Normal file
168
src/routes/(app)/teams/explore/+page.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
|
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
import { IsInViewport } from 'runed'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
||||||
|
const { data } = $props() as { data: PageData }
|
||||||
|
|
||||||
|
const partiesQuery = createInfiniteQuery(() => ({
|
||||||
|
...partyQueries.list(),
|
||||||
|
initialData: data.items
|
||||||
|
? {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
results: data.items,
|
||||||
|
page: data.page || 1,
|
||||||
|
totalPages: data.totalPages,
|
||||||
|
total: data.total,
|
||||||
|
perPage: data.perPage || 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pageParams: [1]
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
initialDataUpdatedAt: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
const items = $derived(
|
||||||
|
partiesQuery.data?.pages.flatMap((page) => page.results) ?? data.items ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEmpty = $derived(!partiesQuery.isLoading && items.length === 0)
|
||||||
|
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
|
||||||
|
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
|
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||||
|
rootMargin: '300px'
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
inViewport.current &&
|
||||||
|
partiesQuery.hasNextPage &&
|
||||||
|
!partiesQuery.isFetchingNextPage &&
|
||||||
|
!partiesQuery.isLoading
|
||||||
|
) {
|
||||||
|
partiesQuery.fetchNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="explore">
|
||||||
|
<header>
|
||||||
|
<h1>Explore Teams</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if partiesQuery.isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading teams...</p>
|
||||||
|
</div>
|
||||||
|
{:else if partiesQuery.isError}
|
||||||
|
<div class="error">
|
||||||
|
<Icon name="alert-circle" size={32} />
|
||||||
|
<p>Failed to load teams: {partiesQuery.error?.message || 'Unknown error'}</p>
|
||||||
|
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else if isEmpty}
|
||||||
|
<div class="empty">
|
||||||
|
<p>No teams found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="explore-grid">
|
||||||
|
<ExploreGrid items={items} />
|
||||||
|
|
||||||
|
{#if showSentinel}
|
||||||
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if partiesQuery.isFetchingNextPage}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !partiesQuery.hasNextPage && items.length > 0}
|
||||||
|
<div class="end">
|
||||||
|
<p>You've reached the end of all teams!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
|
||||||
|
.explore {
|
||||||
|
padding: $unit-2x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 $unit-2x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.end,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--text-error, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
820
src/routes/(app)/teams/new/+page.svelte
Normal file
820
src/routes/(app)/teams/new/+page.svelte
Normal file
|
|
@ -0,0 +1,820 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import WeaponGrid from '$lib/components/grids/WeaponGrid.svelte'
|
||||||
|
import SummonGrid from '$lib/components/grids/SummonGrid.svelte'
|
||||||
|
import CharacterGrid from '$lib/components/grids/CharacterGrid.svelte'
|
||||||
|
import JobSection from '$lib/components/job/JobSection.svelte'
|
||||||
|
import { openSearchSidebar, closeSearchSidebar } from '$lib/features/search/openSearchSidebar.svelte'
|
||||||
|
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
|
||||||
|
import PartySegmentedControl from '$lib/components/party/PartySegmentedControl.svelte'
|
||||||
|
import { GridType } from '$lib/types/enums'
|
||||||
|
import { Gender } from '$lib/utils/jobUtils'
|
||||||
|
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
||||||
|
import { transformSkillsToArray } from '$lib/utils/jobSkills'
|
||||||
|
import { setContext } from 'svelte'
|
||||||
|
import type { SearchResult } from '$lib/api/adapters'
|
||||||
|
import { gridAdapter } from '$lib/api/adapters'
|
||||||
|
import { getLocalId } from '$lib/utils/localId'
|
||||||
|
import { storeEditKey } from '$lib/utils/editKeys'
|
||||||
|
import type { Party } from '$lib/types/api/party'
|
||||||
|
|
||||||
|
// TanStack Query
|
||||||
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
import { partyKeys } from '$lib/api/queries/party.queries'
|
||||||
|
|
||||||
|
// TanStack Query mutations
|
||||||
|
import { useCreateParty } from '$lib/api/mutations/party.mutations'
|
||||||
|
import {
|
||||||
|
useCreateGridWeapon,
|
||||||
|
useCreateGridCharacter,
|
||||||
|
useCreateGridSummon,
|
||||||
|
useDeleteGridWeapon,
|
||||||
|
useDeleteGridCharacter,
|
||||||
|
useDeleteGridSummon
|
||||||
|
} from '$lib/api/mutations/grid.mutations'
|
||||||
|
import { Dialog } from 'bits-ui'
|
||||||
|
import { replaceState } from '$app/navigation'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
data: PageData
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props()
|
||||||
|
|
||||||
|
// Get authentication status from data prop (no store subscription!)
|
||||||
|
let isAuthenticated = $derived(data.isAuthenticated)
|
||||||
|
let currentUser = $derived(data.currentUser)
|
||||||
|
|
||||||
|
// Local, client-only state for tab selection (Svelte 5 runes)
|
||||||
|
let activeTab = $state<GridType>(GridType.Weapon)
|
||||||
|
|
||||||
|
// Open search sidebar on mount
|
||||||
|
let hasOpenedSidebar = $state(false)
|
||||||
|
$effect(() => {
|
||||||
|
if (!hasOpenedSidebar) {
|
||||||
|
hasOpenedSidebar = true
|
||||||
|
// Set initial selected slot to mainhand weapon
|
||||||
|
selectedSlot = -1
|
||||||
|
// Small delay to let the page render first
|
||||||
|
setTimeout(() => {
|
||||||
|
openSearchSidebar({
|
||||||
|
type: 'weapon',
|
||||||
|
onAddItems: handleAddItems,
|
||||||
|
canAddMore: true
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectTab(gridType: GridType) {
|
||||||
|
activeTab = gridType
|
||||||
|
|
||||||
|
// Set selectedSlot to first valid empty slot for this tab
|
||||||
|
if (gridType === GridType.Character) {
|
||||||
|
// Find first empty character slot
|
||||||
|
const emptySlot = [0, 1, 2, 3, 4].find(i => !characters.find(c => c.position === i))
|
||||||
|
selectedSlot = emptySlot ?? 0
|
||||||
|
} else if (gridType === GridType.Weapon) {
|
||||||
|
// Find first empty weapon slot (mainhand first, then grid)
|
||||||
|
const emptySlot = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8].find(i =>
|
||||||
|
!weapons.find(w => w.position === i || (i === -1 && w.mainhand))
|
||||||
|
)
|
||||||
|
selectedSlot = emptySlot ?? -1
|
||||||
|
} else {
|
||||||
|
// Find first empty summon slot (main, grid, friend)
|
||||||
|
const emptySlot = [-1, 0, 1, 2, 3, 6].find(i =>
|
||||||
|
!summons.find(s => s.position === i || (i === -1 && s.main) || (i === 6 && s.friend))
|
||||||
|
)
|
||||||
|
selectedSlot = emptySlot ?? -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open sidebar when switching tabs
|
||||||
|
openSearchSidebar({
|
||||||
|
type: gridType === GridType.Weapon ? 'weapon' :
|
||||||
|
gridType === GridType.Summon ? 'summon' :
|
||||||
|
'character',
|
||||||
|
onAddItems: handleAddItems,
|
||||||
|
canAddMore: !isGridFull(gridType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a grid is full
|
||||||
|
function isGridFull(gridType: GridType): boolean {
|
||||||
|
if (gridType === GridType.Weapon) return weapons.length >= 10
|
||||||
|
if (gridType === GridType.Summon) return summons.length >= 6
|
||||||
|
return characters.length >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job selection handlers
|
||||||
|
async function handleSelectJob() {
|
||||||
|
openJobSelectionSidebar({
|
||||||
|
currentJobId: party.job?.id,
|
||||||
|
onSelectJob: async (job) => {
|
||||||
|
// If party exists, update via API
|
||||||
|
if (partyId && shortcode) {
|
||||||
|
try {
|
||||||
|
await partyAdapter.updateJob(shortcode, job.id)
|
||||||
|
// Cache will be updated via invalidation
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update job:', e)
|
||||||
|
errorMessage = e instanceof Error ? e.message : 'Failed to update job'
|
||||||
|
errorDialogOpen = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update cache locally for new party
|
||||||
|
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
|
||||||
|
if (!old) return placeholderParty
|
||||||
|
return { ...old, job }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectJobSkill(slot: number) {
|
||||||
|
openJobSkillSelectionSidebar({
|
||||||
|
job: party.job,
|
||||||
|
currentSkills: party.jobSkills,
|
||||||
|
targetSlot: slot,
|
||||||
|
onSelectSkill: async (skill) => {
|
||||||
|
// If party exists, update via API
|
||||||
|
if (partyId && shortcode) {
|
||||||
|
try {
|
||||||
|
const updatedSkills = { ...party.jobSkills }
|
||||||
|
updatedSkills[String(slot) as keyof typeof updatedSkills] = skill
|
||||||
|
const skillsArray = transformSkillsToArray(updatedSkills)
|
||||||
|
await partyAdapter.updateJobSkills(shortcode, skillsArray)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update skill:', e)
|
||||||
|
errorMessage = e instanceof Error ? e.message : 'Failed to update skill'
|
||||||
|
errorDialogOpen = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update cache locally for new party
|
||||||
|
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
|
||||||
|
if (!old) return placeholderParty
|
||||||
|
const updatedSkills = { ...old.jobSkills }
|
||||||
|
updatedSkills[String(slot) as keyof typeof updatedSkills] = skill
|
||||||
|
return { ...old, jobSkills: updatedSkills }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoveSkill: async () => {
|
||||||
|
await handleRemoveJobSkill(slot)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveJobSkill(slot: number) {
|
||||||
|
if (partyId && shortcode) {
|
||||||
|
try {
|
||||||
|
const updatedSkills = { ...party.jobSkills }
|
||||||
|
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
|
||||||
|
const skillsArray = transformSkillsToArray(updatedSkills)
|
||||||
|
await partyAdapter.updateJobSkills(shortcode, skillsArray)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to remove skill:', e)
|
||||||
|
errorMessage = e instanceof Error ? e.message : 'Failed to remove skill'
|
||||||
|
errorDialogOpen = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update cache locally for new party
|
||||||
|
queryClient.setQueryData(partyKeys.detail('new'), (old: Party | undefined) => {
|
||||||
|
if (!old) return placeholderParty
|
||||||
|
const updatedSkills = { ...old.jobSkills }
|
||||||
|
delete updatedSkills[String(slot) as keyof typeof updatedSkills]
|
||||||
|
return { ...old, jobSkills: updatedSkills }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Party state
|
||||||
|
let partyId = $state<string | null>(null)
|
||||||
|
let shortcode = $state<string | null>(null)
|
||||||
|
let editKey = $state<string | null>(null)
|
||||||
|
let isCreatingParty = $state(false)
|
||||||
|
|
||||||
|
// Placeholder party for 'new' route
|
||||||
|
const placeholderParty: Party = {
|
||||||
|
id: 'new',
|
||||||
|
shortcode: 'new',
|
||||||
|
name: 'New Team',
|
||||||
|
description: '',
|
||||||
|
weapons: [],
|
||||||
|
summons: [],
|
||||||
|
characters: [],
|
||||||
|
element: 0,
|
||||||
|
visibility: 1,
|
||||||
|
job: undefined,
|
||||||
|
jobSkills: undefined,
|
||||||
|
accessory: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create query with placeholder data
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const partyQuery = createQuery(() => ({
|
||||||
|
...partyQueries.byShortcode(shortcode || 'new'),
|
||||||
|
initialData: placeholderParty,
|
||||||
|
enabled: false // Disable automatic fetching for 'new' party
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Derive state from query
|
||||||
|
const party = $derived(partyQuery.data ?? placeholderParty)
|
||||||
|
const weapons = $derived(party.weapons ?? [])
|
||||||
|
const summons = $derived(party.summons ?? [])
|
||||||
|
const characters = $derived(party.characters ?? [])
|
||||||
|
|
||||||
|
// Derived values for job section
|
||||||
|
const mainWeapon = $derived(weapons.find((w) => w?.mainhand || w?.position === -1))
|
||||||
|
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
|
||||||
|
const partyElement = $derived((party as any)?.element)
|
||||||
|
|
||||||
|
let selectedSlot = $state<number | null>(null)
|
||||||
|
let isFirstItemForSlot = false // Track if this is the first item after clicking empty cell
|
||||||
|
|
||||||
|
// Error dialog state
|
||||||
|
let errorDialogOpen = $state(false)
|
||||||
|
let errorMessage = $state('')
|
||||||
|
let errorDetails = $state<string[]>([])
|
||||||
|
|
||||||
|
// TanStack Query mutations
|
||||||
|
const createPartyMutation = useCreateParty()
|
||||||
|
const createWeaponMutation = useCreateGridWeapon()
|
||||||
|
const createCharacterMutation = useCreateGridCharacter()
|
||||||
|
const createSummonMutation = useCreateGridSummon()
|
||||||
|
const deleteWeapon = useDeleteGridWeapon()
|
||||||
|
const deleteCharacter = useDeleteGridCharacter()
|
||||||
|
const deleteSummon = useDeleteGridSummon()
|
||||||
|
|
||||||
|
// Helper to add item to cache
|
||||||
|
function addItemToCache(itemType: 'weapons' | 'summons' | 'characters', item: any) {
|
||||||
|
const cacheKey = partyKeys.detail(shortcode || 'new')
|
||||||
|
|
||||||
|
queryClient.setQueryData(cacheKey, (old: Party | undefined) => {
|
||||||
|
if (!old) return placeholderParty
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
[itemType]: [...(old[itemType] ?? []), item]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate if grids are full
|
||||||
|
let isWeaponGridFull = $derived(weapons.length >= 10) // 1 mainhand + 9 grid slots
|
||||||
|
let isSummonGridFull = $derived(summons.length >= 6) // 6 summon slots (main + 4 grid + friend)
|
||||||
|
let isCharacterGridFull = $derived(characters.length >= 5) // 5 character slots
|
||||||
|
|
||||||
|
let canAddMore = $derived(
|
||||||
|
activeTab === GridType.Weapon ? !isWeaponGridFull :
|
||||||
|
activeTab === GridType.Summon ? !isSummonGridFull :
|
||||||
|
!isCharacterGridFull
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle adding items from search
|
||||||
|
async function handleAddItems(items: SearchResult[]) {
|
||||||
|
console.log('Adding items:', items, 'to tab:', activeTab)
|
||||||
|
|
||||||
|
// Create party on first item if not already created
|
||||||
|
if (!partyId && !isCreatingParty && items.length > 0) {
|
||||||
|
isCreatingParty = true
|
||||||
|
const firstItem = items[0]
|
||||||
|
|
||||||
|
// Guard against undefined firstItem (shouldn't happen given items.length > 0 check, but TypeScript needs this)
|
||||||
|
if (!firstItem) {
|
||||||
|
isCreatingParty = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create the party (with local_id only for anonymous users)
|
||||||
|
const partyPayload: any = {
|
||||||
|
name: 'New Team',
|
||||||
|
visibility: 1, // 1 = Public, 2 = Unlisted, 3 = Private
|
||||||
|
element: firstItem.element || 0 // Use item's element or default to null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include localId for anonymous users
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
partyPayload.localId = getLocalId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create party using mutation
|
||||||
|
const createdParty = await createPartyMutation.mutateAsync(partyPayload)
|
||||||
|
console.log('Party created:', createdParty)
|
||||||
|
|
||||||
|
// The adapter returns the party directly
|
||||||
|
partyId = createdParty.id
|
||||||
|
shortcode = createdParty.shortcode
|
||||||
|
|
||||||
|
// Store edit key for anonymous editing under BOTH identifiers
|
||||||
|
// - shortcode: for Party.svelte which uses shortcode as partyId
|
||||||
|
// - UUID: for /teams/new which uses UUID as partyId
|
||||||
|
if (createdParty.editKey) {
|
||||||
|
storeEditKey(createdParty.shortcode, createdParty.editKey)
|
||||||
|
storeEditKey(createdParty.id, createdParty.editKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partyId || !shortcode) {
|
||||||
|
throw new Error('Party creation did not return ID or shortcode')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the query cache with the created party
|
||||||
|
queryClient.setQueryData(
|
||||||
|
partyKeys.detail(createdParty.shortcode),
|
||||||
|
createdParty
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 2: Add the first item to the party
|
||||||
|
let position = selectedSlot !== null ? selectedSlot : -1 // Use selectedSlot if available
|
||||||
|
let itemAdded = false
|
||||||
|
try {
|
||||||
|
console.log('Adding item to party:', { partyId, itemId: firstItem.id, type: activeTab, position })
|
||||||
|
|
||||||
|
if (activeTab === GridType.Weapon) {
|
||||||
|
// Use selectedSlot if available, otherwise default to mainhand
|
||||||
|
if (selectedSlot === null) position = -1
|
||||||
|
const addResult = await createWeaponMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
weaponId: firstItem.granblueId,
|
||||||
|
position,
|
||||||
|
mainhand: position === -1
|
||||||
|
})
|
||||||
|
console.log('Weapon added:', addResult)
|
||||||
|
itemAdded = true
|
||||||
|
|
||||||
|
// Update cache with the added weapon
|
||||||
|
addItemToCache('weapons', addResult)
|
||||||
|
} else if (activeTab === GridType.Summon) {
|
||||||
|
// Use selectedSlot if available, otherwise default to main summon
|
||||||
|
if (selectedSlot === null) position = -1
|
||||||
|
const addResult = await createSummonMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
summonId: firstItem.granblueId,
|
||||||
|
position,
|
||||||
|
main: position === -1,
|
||||||
|
friend: position === 6
|
||||||
|
})
|
||||||
|
console.log('Summon added:', addResult)
|
||||||
|
itemAdded = true
|
||||||
|
|
||||||
|
// Update cache with the added summon
|
||||||
|
addItemToCache('summons', addResult)
|
||||||
|
} else if (activeTab === GridType.Character) {
|
||||||
|
// Use selectedSlot if available, otherwise default to first slot
|
||||||
|
if (selectedSlot === null) position = 0
|
||||||
|
const addResult = await createCharacterMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
characterId: firstItem.granblueId,
|
||||||
|
position
|
||||||
|
})
|
||||||
|
console.log('Character added:', addResult)
|
||||||
|
itemAdded = true
|
||||||
|
|
||||||
|
// Update cache with the added character
|
||||||
|
addItemToCache('characters', addResult)
|
||||||
|
}
|
||||||
|
selectedSlot = null // Reset after using
|
||||||
|
|
||||||
|
// Update URL without redirecting
|
||||||
|
if (itemAdded && shortcode) {
|
||||||
|
// Update the URL to reflect the new party without navigating
|
||||||
|
replaceState(`/teams/${shortcode}`, {})
|
||||||
|
// Continue to allow adding more items
|
||||||
|
}
|
||||||
|
} catch (addError: any) {
|
||||||
|
console.error('Failed to add first item:', addError)
|
||||||
|
// Show error to user but don't redirect
|
||||||
|
errorMessage = addError.message || 'Failed to add item to party'
|
||||||
|
errorDetails = addError.details || []
|
||||||
|
errorDialogOpen = true
|
||||||
|
// Still update URL to the created party even if item failed
|
||||||
|
if (shortcode) {
|
||||||
|
replaceState(`/teams/${shortcode}`, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingParty = false // Reset flag after party creation completes
|
||||||
|
|
||||||
|
// If there are more items to add, continue processing them
|
||||||
|
if (items.length > 1) {
|
||||||
|
const remainingItems = items.slice(1)
|
||||||
|
await handleAddItems(remainingItems) // Recursive call to add remaining items
|
||||||
|
}
|
||||||
|
return // Exit after processing all items from party creation
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create party:', error)
|
||||||
|
isCreatingParty = false
|
||||||
|
|
||||||
|
// Parse error message
|
||||||
|
if (error.message) {
|
||||||
|
errorMessage = error.message
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Failed to create party'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse validation errors if present
|
||||||
|
if (error.details && Array.isArray(error.details)) {
|
||||||
|
errorDetails = error.details
|
||||||
|
} else if (error.errors && typeof error.errors === 'object') {
|
||||||
|
// Rails-style validation errors
|
||||||
|
errorDetails = Object.entries(error.errors).map(
|
||||||
|
([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
errorDetails = []
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDialogOpen = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If party already exists, add items using grid API
|
||||||
|
if (partyId && !isCreatingParty) {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]
|
||||||
|
if (!item) continue // Skip undefined items
|
||||||
|
let position = -1 // Default position
|
||||||
|
|
||||||
|
if (activeTab === GridType.Weapon) {
|
||||||
|
// Use selectedSlot for first item if available
|
||||||
|
if (i === 0 && selectedSlot !== null && !weapons.find(w => w.position === selectedSlot)) {
|
||||||
|
position = selectedSlot
|
||||||
|
selectedSlot = null // Reset after using
|
||||||
|
} else {
|
||||||
|
// Find next empty weapon slot
|
||||||
|
const emptySlots = Array.from({ length: 10 }, (_, i) => i - 1)
|
||||||
|
.filter(i => !weapons.find(w => w.position === i))
|
||||||
|
if (emptySlots.length === 0) return // Grid full
|
||||||
|
position = emptySlots[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add weapon via API
|
||||||
|
const response = await createWeaponMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
weaponId: item.granblueId,
|
||||||
|
position,
|
||||||
|
mainhand: position === -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
addItemToCache('weapons', response)
|
||||||
|
} else if (activeTab === GridType.Summon) {
|
||||||
|
// Use selectedSlot for first item if available
|
||||||
|
if (i === 0 && selectedSlot !== null && !summons.find(s => s.position === selectedSlot)) {
|
||||||
|
position = selectedSlot
|
||||||
|
selectedSlot = null // Reset after using
|
||||||
|
} else {
|
||||||
|
// Find next empty summon slot
|
||||||
|
const emptySlots = [-1, 0, 1, 2, 3, 6] // main, 4 grid slots, friend
|
||||||
|
.filter(i => !summons.find(s => s.position === i))
|
||||||
|
if (emptySlots.length === 0) return // Grid full
|
||||||
|
position = emptySlots[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add summon via API
|
||||||
|
const response = await createSummonMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
summonId: item.granblueId,
|
||||||
|
position,
|
||||||
|
main: position === -1,
|
||||||
|
friend: position === 6
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
addItemToCache('summons', response)
|
||||||
|
} else if (activeTab === GridType.Character) {
|
||||||
|
// Use selectedSlot for first item if available
|
||||||
|
if (i === 0 && selectedSlot !== null && !characters.find(c => c.position === selectedSlot)) {
|
||||||
|
position = selectedSlot
|
||||||
|
selectedSlot = null // Reset after using
|
||||||
|
} else {
|
||||||
|
// Find next empty character slot
|
||||||
|
const emptySlots = Array.from({ length: 5 }, (_, i) => i)
|
||||||
|
.filter(i => !characters.find(c => c.position === i))
|
||||||
|
if (emptySlots.length === 0) return // Grid full
|
||||||
|
position = emptySlots[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add character via API
|
||||||
|
const response = await createCharacterMutation.mutateAsync({
|
||||||
|
partyId,
|
||||||
|
characterId: item.granblueId,
|
||||||
|
position
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
addItemToCache('characters', response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add item:', error)
|
||||||
|
errorMessage = error.message || 'Failed to add item'
|
||||||
|
errorDetails = error.details || []
|
||||||
|
errorDialogOpen = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Provide party context using query data
|
||||||
|
setContext('party', {
|
||||||
|
getParty: () => party,
|
||||||
|
updateParty: (p: Party) => {
|
||||||
|
// Update cache instead of local state
|
||||||
|
queryClient.setQueryData(partyKeys.detail(shortcode || 'new'), p)
|
||||||
|
},
|
||||||
|
canEdit: () => true,
|
||||||
|
getEditKey: () => editKey,
|
||||||
|
getSelectedSlot: () => selectedSlot,
|
||||||
|
getActiveTab: () => activeTab,
|
||||||
|
services: {
|
||||||
|
gridService: {
|
||||||
|
removeWeapon: async (partyId: string, itemId: string) => {
|
||||||
|
if (!partyId || partyId === 'new') return party
|
||||||
|
await deleteWeapon.mutateAsync({
|
||||||
|
id: itemId,
|
||||||
|
partyId,
|
||||||
|
partyShortcode: shortcode || 'new'
|
||||||
|
})
|
||||||
|
return party
|
||||||
|
},
|
||||||
|
removeSummon: async (partyId: string, itemId: string) => {
|
||||||
|
if (!partyId || partyId === 'new') return party
|
||||||
|
await deleteSummon.mutateAsync({
|
||||||
|
id: itemId,
|
||||||
|
partyId,
|
||||||
|
partyShortcode: shortcode || 'new'
|
||||||
|
})
|
||||||
|
return party
|
||||||
|
},
|
||||||
|
removeCharacter: async (partyId: string, itemId: string) => {
|
||||||
|
if (!partyId || partyId === 'new') return party
|
||||||
|
await deleteCharacter.mutateAsync({
|
||||||
|
id: itemId,
|
||||||
|
partyId,
|
||||||
|
partyShortcode: shortcode || 'new'
|
||||||
|
})
|
||||||
|
return party
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openPicker: (opts: { type: 'weapon' | 'summon' | 'character'; position: number; item?: any }) => {
|
||||||
|
selectedSlot = opts.position
|
||||||
|
openSearchSidebar({
|
||||||
|
type: opts.type,
|
||||||
|
onAddItems: handleAddItems,
|
||||||
|
canAddMore: !isGridFull(
|
||||||
|
opts.type === 'weapon' ? GridType.Weapon :
|
||||||
|
opts.type === 'summon' ? GridType.Summon :
|
||||||
|
GridType.Character
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-container">
|
||||||
|
<section class="party-content">
|
||||||
|
<header class="party-header">
|
||||||
|
<div class="party-info">
|
||||||
|
<h1>Create a new team</h1>
|
||||||
|
<p class="description">Search and click items to add them to your grid</p>
|
||||||
|
</div>
|
||||||
|
<button class="toggle-sidebar" on:click={() => openSearchSidebar({
|
||||||
|
type: activeTab === GridType.Weapon ? 'weapon' :
|
||||||
|
activeTab === GridType.Summon ? 'summon' :
|
||||||
|
'character',
|
||||||
|
onAddItems: handleAddItems,
|
||||||
|
canAddMore: !isGridFull(activeTab)
|
||||||
|
})}>
|
||||||
|
Open Search
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PartySegmentedControl
|
||||||
|
selectedTab={activeTab}
|
||||||
|
onTabChange={selectTab}
|
||||||
|
party={{
|
||||||
|
id: '',
|
||||||
|
shortcode: '',
|
||||||
|
element: 0,
|
||||||
|
job: undefined,
|
||||||
|
characters,
|
||||||
|
weapons,
|
||||||
|
summons
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="party-content">
|
||||||
|
{#if activeTab === GridType.Weapon}
|
||||||
|
<WeaponGrid {weapons} />
|
||||||
|
{:else if activeTab === GridType.Summon}
|
||||||
|
<SummonGrid {summons} />
|
||||||
|
{:else}
|
||||||
|
<div class="character-tab-content">
|
||||||
|
<JobSection
|
||||||
|
job={party.job}
|
||||||
|
jobSkills={party.jobSkills}
|
||||||
|
accessory={party.accessory}
|
||||||
|
canEdit={true}
|
||||||
|
gender={Gender.Gran}
|
||||||
|
element={mainWeaponElement}
|
||||||
|
onSelectJob={handleSelectJob}
|
||||||
|
onSelectSkill={handleSelectJobSkill}
|
||||||
|
onRemoveSkill={handleRemoveJobSkill}
|
||||||
|
onSelectAccessory={() => {
|
||||||
|
console.log('Open accessory selection sidebar')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CharacterGrid
|
||||||
|
{characters}
|
||||||
|
{mainWeaponElement}
|
||||||
|
{partyElement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Error Dialog -->
|
||||||
|
<Dialog.Root bind:open={errorDialogOpen}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
|
<Dialog.Content class="dialog-content">
|
||||||
|
<Dialog.Title class="dialog-title">Error Creating Team</Dialog.Title>
|
||||||
|
<Dialog.Description class="dialog-description">
|
||||||
|
{errorMessage}
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
{#if errorDetails.length > 0}
|
||||||
|
<div class="error-details">
|
||||||
|
<p class="error-details-title">Details:</p>
|
||||||
|
<ul class="error-list">
|
||||||
|
{#each errorDetails as detail}
|
||||||
|
<li>{detail}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<Dialog.Close class="dialog-button">
|
||||||
|
OK
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Override the main element's padding for this page */
|
||||||
|
:global(main) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-info h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-sidebar {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3366ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-sidebar:hover {
|
||||||
|
background: #2857e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-tab-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog styles */
|
||||||
|
:global(.dialog-overlay) {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dialog-content) {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 51;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-description {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-list li {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #3366ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-button:hover {
|
||||||
|
background: #2857e0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/routes/(app)/teams/new/+page.ts
Normal file
10
src/routes/(app)/teams/new/+page.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
|
const parentData = await parent()
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: parentData.isAuthenticated ?? false,
|
||||||
|
currentUser: parentData.currentUser ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/routes/(app)/test/+layout.svelte
Normal file
15
src/routes/(app)/test/+layout.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script>
|
||||||
|
// Simple layout for test pages
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-layout">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.test-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
140
src/routes/(app)/test/context-menu/+page.svelte
Normal file
140
src/routes/(app)/test/context-menu/+page.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import UnitMenuContainer from '$lib/components/ui/menu/UnitMenuContainer.svelte'
|
||||||
|
import MenuItems from '$lib/components/ui/menu/MenuItems.svelte'
|
||||||
|
|
||||||
|
let message = $state('No action yet')
|
||||||
|
|
||||||
|
function handleViewDetails() {
|
||||||
|
message = 'View Details clicked'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplace() {
|
||||||
|
message = 'Replace clicked'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
message = 'Remove clicked'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-page">
|
||||||
|
<h1>Context Menu Test Page</h1>
|
||||||
|
|
||||||
|
<p class="instructions">
|
||||||
|
Test both interaction methods:
|
||||||
|
</p>
|
||||||
|
<ul class="instructions">
|
||||||
|
<li><strong>Right-click</strong> on the weapon image to open the context menu</li>
|
||||||
|
<li><strong>Hover</strong> over the weapon to see the gear button appear</li>
|
||||||
|
<li><strong>Click</strong> the gear button to open the dropdown menu</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<div class="test-unit">
|
||||||
|
<UnitMenuContainer showGearButton={true}>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<img
|
||||||
|
src="/images/placeholders/placeholder-weapon-grid.png"
|
||||||
|
alt="Test weapon"
|
||||||
|
class="test-image"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet contextMenu()}
|
||||||
|
<MenuItems
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onReplace={handleReplace}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
canEdit={true}
|
||||||
|
variant="context"
|
||||||
|
viewDetailsLabel="View Details"
|
||||||
|
replaceLabel="Replace"
|
||||||
|
removeLabel="Remove"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet dropdownMenu()}
|
||||||
|
<MenuItems
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onReplace={handleReplace}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
canEdit={true}
|
||||||
|
variant="dropdown"
|
||||||
|
viewDetailsLabel="View Details"
|
||||||
|
replaceLabel="Replace"
|
||||||
|
removeLabel="Remove"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</UnitMenuContainer>
|
||||||
|
<div class="test-label">Hover me or right-click</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result">
|
||||||
|
<strong>Last action:</strong> {message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.test-page {
|
||||||
|
padding: $unit-4x;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: $font-xxlarge;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
&.instructions {
|
||||||
|
padding-left: $unit-3x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-8x;
|
||||||
|
background: var(--app-bg-secondary);
|
||||||
|
border-radius: $unit;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-unit {
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-image {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: $unit;
|
||||||
|
background: var(--card-bg);
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-label {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: $unit;
|
||||||
|
font-size: $font-regular;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
450
src/routes/(app)/test/images/+page.svelte
Normal file
450
src/routes/(app)/test/images/+page.svelte
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getImageUrl,
|
||||||
|
getCharacterPose,
|
||||||
|
type ResourceType,
|
||||||
|
type ImageVariant
|
||||||
|
} from '$lib/utils/images'
|
||||||
|
|
||||||
|
// State for selections
|
||||||
|
let resourceType: ResourceType = $state('character')
|
||||||
|
let variant: ImageVariant = $state('main')
|
||||||
|
let itemId = $state('3030182000') // Gran/Djeeta as default
|
||||||
|
let pose = $state('01')
|
||||||
|
let uncapLevel = $state(0)
|
||||||
|
let transcendenceStep = $state(0)
|
||||||
|
let weaponElement = $state(0)
|
||||||
|
let customPose = $state(false)
|
||||||
|
|
||||||
|
// Sample item IDs for testing
|
||||||
|
const sampleIds = {
|
||||||
|
character: [
|
||||||
|
{ id: '3030182000', name: 'Gran/Djeeta (Element-specific)' },
|
||||||
|
{ id: '3020000000', name: 'Katalina' },
|
||||||
|
{ id: '3020001000', name: 'Rackam' },
|
||||||
|
{ id: '3020002000', name: 'Io' },
|
||||||
|
{ id: '3040000000', name: 'Charlotta' }
|
||||||
|
],
|
||||||
|
weapon: [
|
||||||
|
{ id: '1040000000', name: 'Sword' },
|
||||||
|
{ id: '1040001000', name: 'Luminiera Sword' },
|
||||||
|
{ id: '1040500000', name: 'Bahamut Sword' },
|
||||||
|
{ id: '1040019000', name: 'Opus Sword' }
|
||||||
|
],
|
||||||
|
summon: [
|
||||||
|
{ id: '2040000000', name: 'Colossus' },
|
||||||
|
{ id: '2040001000', name: 'Leviathan' },
|
||||||
|
{ id: '2040002000', name: 'Tiamat' },
|
||||||
|
{ id: '2040003000', name: 'Yggdrasil' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available variants per resource type
|
||||||
|
const availableVariants = $derived.by(() => {
|
||||||
|
const base: ImageVariant[] = ['main', 'grid', 'square']
|
||||||
|
if (resourceType === 'character') {
|
||||||
|
return [...base, 'detail']
|
||||||
|
} else if (resourceType === 'weapon') {
|
||||||
|
return [...base, 'base']
|
||||||
|
} else {
|
||||||
|
return [...base, 'detail', 'wide']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-calculate pose based on uncap/transcendence
|
||||||
|
const calculatedPose = $derived(
|
||||||
|
customPose ? pose : getCharacterPose(uncapLevel, transcendenceStep)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle Gran/Djeeta element-specific poses
|
||||||
|
const finalPose = $derived.by(() => {
|
||||||
|
if (resourceType !== 'character') return undefined
|
||||||
|
|
||||||
|
let p = calculatedPose
|
||||||
|
if (itemId === '3030182000' && weaponElement > 0) {
|
||||||
|
p = `${p}_0${weaponElement}`
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generated image URL
|
||||||
|
const imageUrl = $derived(
|
||||||
|
getImageUrl(resourceType as ResourceType, itemId || null, variant as ImageVariant, {
|
||||||
|
pose: finalPose,
|
||||||
|
element: (resourceType as ResourceType) === 'weapon' && (variant as ImageVariant) === 'grid' ? weaponElement : undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// File extension display
|
||||||
|
const fileExtension = $derived.by(() => {
|
||||||
|
if (resourceType === 'character' && variant === 'detail') return '.png'
|
||||||
|
if (resourceType === 'weapon' && variant === 'base') return '.png'
|
||||||
|
if (resourceType === 'summon' && variant === 'detail') return '.png'
|
||||||
|
return '.jpg'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset variant if not available
|
||||||
|
$effect(() => {
|
||||||
|
if (!availableVariants.includes(variant)) {
|
||||||
|
variant = 'main'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h1>Image Utility Test Page</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<section>
|
||||||
|
<h2>Resource Type</h2>
|
||||||
|
<div class="radio-group">
|
||||||
|
{#each ['character', 'weapon', 'summon'] as type}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={resourceType} value={type} />
|
||||||
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Image Variant</h2>
|
||||||
|
<div class="radio-group">
|
||||||
|
{#each availableVariants as v}
|
||||||
|
<label class:special={fileExtension === '.png' && variant === v}>
|
||||||
|
<input type="radio" bind:group={variant} value={v} />
|
||||||
|
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||||
|
{#if (resourceType === 'character' && v === 'detail') || (resourceType === 'weapon' && v === 'base') || (resourceType === 'summon' && v === 'detail')}
|
||||||
|
<span class="badge">PNG</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Item Selection</h2>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={itemId} value="" />
|
||||||
|
None (Placeholder)
|
||||||
|
</label>
|
||||||
|
{#each sampleIds[resourceType] as item}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={itemId} value={item.id} />
|
||||||
|
{item.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="custom-id">
|
||||||
|
<label>
|
||||||
|
Custom ID:
|
||||||
|
<input type="text" bind:value={itemId} placeholder="Enter Granblue ID" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if resourceType === 'character'}
|
||||||
|
<section>
|
||||||
|
<h2>Character Pose</h2>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={customPose} />
|
||||||
|
Manual pose control
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if customPose}
|
||||||
|
<div class="radio-group">
|
||||||
|
{#each ['01', '02', '03', '04'] as p}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={pose} value={p} />
|
||||||
|
Pose {p}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="slider-group">
|
||||||
|
<label>
|
||||||
|
Uncap Level: {uncapLevel}
|
||||||
|
<input type="range" bind:value={uncapLevel} min="0" max="6" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Transcendence: {transcendenceStep}
|
||||||
|
<input type="range" bind:value={transcendenceStep} min="0" max="5" />
|
||||||
|
</label>
|
||||||
|
<div class="info">Calculated Pose: {calculatedPose}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if itemId === '3030182000'}
|
||||||
|
<div class="element-group">
|
||||||
|
<h3>Gran/Djeeta Element</h3>
|
||||||
|
<div class="radio-group">
|
||||||
|
{#each [{ value: 0, label: 'None' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={weaponElement} value={elem.value} />
|
||||||
|
{elem.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if resourceType === 'weapon' && variant === 'grid'}
|
||||||
|
<section>
|
||||||
|
<h2>Weapon Element (Grid Only)</h2>
|
||||||
|
<div class="radio-group">
|
||||||
|
{#each [{ value: 0, label: 'Default' }, { value: 1, label: 'Wind' }, { value: 2, label: 'Fire' }, { value: 3, label: 'Water' }, { value: 4, label: 'Earth' }, { value: 5, label: 'Dark' }, { value: 6, label: 'Light' }] as elem}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={weaponElement} value={elem.value} />
|
||||||
|
{elem.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output">
|
||||||
|
<section class="url-display">
|
||||||
|
<h2>Generated URL</h2>
|
||||||
|
<code>{imageUrl}</code>
|
||||||
|
<div class="path-info">
|
||||||
|
<span>Directory: <strong>{resourceType}-{variant}</strong></span>
|
||||||
|
<span>Extension: <strong>{fileExtension}</strong></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="image-display">
|
||||||
|
<h2>Image Preview</h2>
|
||||||
|
<div class="image-container" data-variant={variant}>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Test image"
|
||||||
|
on:error={(e) => {
|
||||||
|
e.currentTarget.classList.add('error')
|
||||||
|
}}
|
||||||
|
on:load={(e) => {
|
||||||
|
e.currentTarget.classList.remove('error')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="note">Note: Image will show error state if file doesn't exist</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
padding: $unit-2x;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: $font-large;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: $font-regular;
|
||||||
|
margin-bottom: $unit-half;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: var(--background-secondary, $grey-90);
|
||||||
|
border: 1px solid var(--border-color, $grey-80);
|
||||||
|
border-radius: $card-corner;
|
||||||
|
padding: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group,
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: $unit-half;
|
||||||
|
border-radius: $item-corner-small;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.special {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: $font-tiny;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: rgb(59, 130, 246);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-id {
|
||||||
|
margin-top: $unit;
|
||||||
|
padding-top: $unit;
|
||||||
|
border-top: 1px solid var(--border-color, $grey-80);
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
background: var(--input-bg, $grey-95);
|
||||||
|
border: 1px solid var(--border-color, $grey-80);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-blue, #3b82f6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
padding: $unit-half;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-radius: $item-corner-small;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: $medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-group {
|
||||||
|
margin-top: $unit;
|
||||||
|
padding-top: $unit;
|
||||||
|
border-top: 1px solid var(--border-color, $grey-80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
display: grid;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-display {
|
||||||
|
code {
|
||||||
|
display: block;
|
||||||
|
padding: $unit;
|
||||||
|
background: var(--code-bg, $grey-95);
|
||||||
|
border-radius: $item-corner-small;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: $font-small;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-info {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-display {
|
||||||
|
.image-container {
|
||||||
|
background: $grey-95;
|
||||||
|
border: 2px dashed $grey-80;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
padding: $unit-2x;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&[data-variant='detail'],
|
||||||
|
&[data-variant='base'] {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='wide'] {
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
opacity: 0.3;
|
||||||
|
filter: grayscale(1);
|
||||||
|
border: 2px solid red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: $unit;
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue