## Summary
This PR establishes the foundation for migrating from custom Svelte 5
resource classes to TanStack Query v6 for server state management. It
adds:
**Query Options Factories** (in `src/lib/api/queries/`):
- `party.queries.ts` - Party fetching with infinite scroll support
- `job.queries.ts` - Job and skill queries with pagination
- `user.queries.ts` - User profile, parties, and favorites queries
**Mutation Configurations** (in `src/lib/api/mutations/`):
- `party.mutations.ts` - Party CRUD with cache invalidation
- `grid.mutations.ts` - Weapon/character/summon mutations with
optimistic updates
- `job.mutations.ts` - Job and skill update mutations
**Deprecation Notices**:
- Added `@deprecated` JSDoc to `search.resource.svelte.ts` and
`party.resource.svelte.ts` with migration examples
**SSR Integration** (Phase 4):
- Created `+layout.ts` to initialize QueryClient for SSR support
- Updated `+layout.svelte` to receive QueryClient from load function
- Added SSR utilities in `src/lib/query/ssr.ts`:
- `withInitialData()` - for pages using +page.server.ts
- `prefetchQuery()` / `prefetchInfiniteQuery()` - for pages using
+page.ts
- `setQueryData()` - for direct cache population
- Added documentation in `src/lib/query/README.md`
**Component Wiring Examples** (Phase 5):
- `JobSelectionSidebar.svelte` - Migrated from `createJobResource()` to
`createQuery(() => jobQueries.list())`. Demonstrates client-side query
pattern with automatic loading/error states.
- `teams/[id]/+page.svelte` - Added `withInitialData()` pattern for SSR
integration. Server-fetched party data is used as initial cache value
with background refetching support.
**Migration Guide**:
- Added `src/lib/query/MIGRATION.md` with follow-up prompts for
remaining component migrations (JobSkillSelectionSidebar, search modal,
user profile, teams explore, Party mutations, resource class removal)
## Updates Since Last Revision
Fixed TypeScript type errors in the TanStack Query integration:
- `party.queries.ts`: Made `total` and `perPage` optional in
`PartyPageResult` interface to match adapter return type
- `ssr.ts`: Fixed `withInitialData` to properly handle null values using
`NonNullable<TData>` return type
- `job.mutations.ts`: Fixed slot indexing by casting through `unknown`
to `keyof typeof updatedSkills`
Type checks now pass for all files modified in this PR (16 remaining
errors are pre-existing project issues unrelated to this PR - paraglide
modules not generated, hooks.ts implicit anys).
## Review & Testing Checklist for Human
- [ ] **Verify app loads correctly**: The `+layout.ts` and
`+layout.svelte` changes are critical path - confirm the app still
renders
- [ ] **Test JobSelectionSidebar**: Open job selection sidebar and
verify jobs load correctly, search/filter works, and retry button works
on error
- [ ] **Test teams/[id] page**: Navigate to a party detail page and
verify it renders without loading flash (SSR data should be immediate)
- [ ] **Review type casts**: Check `job.mutations.ts:135` - the `as
unknown as keyof typeof` cast for slot indexing is a workaround for
jobSkills having string literal keys ('0', '1', '2', '3') while slot is
a number
- [ ] **Verify withInitialData behavior**: The `NonNullable<TData>`
return type change in `ssr.ts` should work correctly with `data.party`
which can be `Party | null`
**Recommended test plan**:
1. Run `pnpm install` to ensure dependencies are up to date
2. Start dev server and verify the app loads without errors
3. Navigate to a party detail page (`/teams/[shortcode]`) - should
render immediately without loading state
4. Open job selection sidebar (click job icon on a party you can edit) -
verify jobs load and filtering works
5. Test error handling by temporarily breaking network - verify retry
button appears
### Notes
- Pre-existing project issues remain (paraglide modules not generated,
hooks.ts implicit anys) - these are unrelated to this PR
- Local testing could not run due to missing node_modules (vite not
found) - project setup issue
- TanStack Query devtools installation was skipped due to Storybook
version conflicts
- The existing `search.queries.ts` file was used as the pattern
reference for new query factories
- SSR approach uses hybrid pattern: existing `+page.server.ts` files
work with `withInitialData()`, while new pages can use `prefetchQuery()`
in `+page.ts`
- Migration guide includes 6 follow-up prompts for completing the
remaining component migrations
**Link to Devin run**:
https://app.devin.ai/sessions/33e97a98ae3e415aa4dc35378cad3a2b
**Requested by**: Justin Edmund (justin@jedmund.com) / @jedmund
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
322 lines
8.3 KiB
Svelte
322 lines
8.3 KiB
Svelte
<script lang="ts">
|
|
import favicon from '$lib/assets/favicon.svg'
|
|
import 'modern-normalize/modern-normalize.css'
|
|
import '$src/app.scss'
|
|
|
|
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 { authStore } from '$lib/stores/auth.store'
|
|
import { browser } from '$app/environment'
|
|
import { QueryClientProvider } from '@tanstack/svelte-query'
|
|
import type { LayoutData } from './$types'
|
|
|
|
// Get `data` and `children` from the router via $props()
|
|
// QueryClient is now created in +layout.ts for SSR support
|
|
const { data, children } = $props<{
|
|
data: LayoutData & { [key: string]: any } // Allow any data to pass through from child pages
|
|
children: () => any
|
|
}>()
|
|
|
|
// Reference to the scrolling container
|
|
let mainContent: HTMLElement | undefined;
|
|
|
|
// Store scroll positions for each visited route
|
|
const scrollPositions = new Map<string, number>();
|
|
|
|
// Initialize auth store from server data immediately on load to ensure
|
|
// Authorization headers are available for client-side API calls
|
|
// Run immediately, not in effect to avoid timing issues
|
|
if (browser) {
|
|
if (data?.auth) {
|
|
console.log('[+layout] Initializing authStore with token:', data.auth.accessToken ? 'present' : 'missing')
|
|
authStore.initFromServer(
|
|
data.auth.accessToken,
|
|
data.auth.user,
|
|
data.auth.expiresAt
|
|
)
|
|
} else {
|
|
console.warn('[+layout] No auth data available to initialize authStore')
|
|
}
|
|
}
|
|
|
|
// 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: (data: any) => {
|
|
if (!data || !mainContent) return;
|
|
|
|
// Restore saved positions map
|
|
if (data.positions) {
|
|
scrollPositions.clear();
|
|
data.positions.forEach(([key, value]: [string, number]) => {
|
|
scrollPositions.set(key, value);
|
|
});
|
|
}
|
|
|
|
// Restore current scroll position after DOM is ready
|
|
if (browser) {
|
|
requestAnimationFrame(() => {
|
|
if (mainContent) mainContent.scrollTop = data.scroll || 0;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<link rel="icon" href={favicon} />
|
|
</svelte:head>
|
|
|
|
<QueryClientProvider client={data.queryClient}>
|
|
<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}
|
|
>
|
|
{#if sidebar.component}
|
|
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
|
|
{:else if sidebar.content}
|
|
{@render sidebar.content()}
|
|
{/if}
|
|
</Sidebar>
|
|
</div>
|
|
</Tooltip.Provider>
|
|
</QueryClientProvider>
|
|
|
|
<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
|
|
|
|
// Note: scroll-behavior removed to prevent unwanted animations
|
|
// Scroll is controlled programmatically in the script
|
|
|
|
// 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>
|