add database pages with svar grid
This commit is contained in:
parent
1c5caccf5e
commit
034e8e48cd
18 changed files with 1082 additions and 0 deletions
325
src/lib/components/database/DatabaseGrid.svelte
Normal file
325
src/lib/components/database/DatabaseGrid.svelte
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
<!--
|
||||||
|
DatabaseGrid component using SVAR DataGrid for Svelte 5
|
||||||
|
Provides server-side pagination, sorting, and filtering for database tables
|
||||||
|
-->
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Grid } from 'wx-svelte-grid'
|
||||||
|
import type { IColumn, IRow } from 'wx-svelte-grid'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: any[]
|
||||||
|
columns: IColumn[]
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
onPageSizeChange: (pageSize: number) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
loading = false
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let searchTerm = $state('')
|
||||||
|
|
||||||
|
// Computed values for pagination display
|
||||||
|
const startItem = $derived((page - 1) * pageSize + 1)
|
||||||
|
const endItem = $derived(Math.min(page * pageSize, total))
|
||||||
|
|
||||||
|
// Handle pagination controls
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
if (page > 1) {
|
||||||
|
onPageChange(page - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (page < totalPages) {
|
||||||
|
onPageChange(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement
|
||||||
|
onPageSizeChange(Number(target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter data based on search term (client-side for now)
|
||||||
|
const filteredData = $derived.by(() => {
|
||||||
|
if (!searchTerm) return data
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return data.filter(item => {
|
||||||
|
// Search across all string fields
|
||||||
|
return Object.values(item).some(value =>
|
||||||
|
typeof value === 'string' && value.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grid configuration
|
||||||
|
let api: any
|
||||||
|
|
||||||
|
const init = (apiRef: any) => {
|
||||||
|
api = apiRef
|
||||||
|
console.log('[DatabaseGrid] Grid API initialized:', api)
|
||||||
|
console.log('[DatabaseGrid] Data passed to grid:', data)
|
||||||
|
console.log('[DatabaseGrid] Columns passed to grid:', columns)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-grid">
|
||||||
|
<div class="grid-controls">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-size-selector">
|
||||||
|
<label for="page-size">Show:</label>
|
||||||
|
<select id="page-size" value={pageSize} onchange={handlePageSizeChange}>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-wrapper" class:loading>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-spinner">Loading...</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
data={filteredData}
|
||||||
|
{columns}
|
||||||
|
{init}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-footer">
|
||||||
|
<div class="pagination-info">
|
||||||
|
Showing {startItem} to {endItem} of {total} entries
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button
|
||||||
|
class="pagination-button"
|
||||||
|
onclick={handlePrevPage}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="page-display">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="pagination-button"
|
||||||
|
onclick={handleNextPage}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-grid {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: spacing.$unit * 0.5 spacing.$unit;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit * 0.5;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: spacing.$unit * 0.25 spacing.$unit * 0.5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-height: 400px;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
font-size: typography.$font-medium;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: spacing.$unit;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
background: #f8f9fa;
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
.pagination-button {
|
||||||
|
padding: spacing.$unit * 0.5 spacing.$unit;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-display {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: #495057;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global styles for SVAR Grid elements
|
||||||
|
:global(.database-grid .wx-grid) {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.database-grid .wx-table-box) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.wx-grid .wx-header-cell) {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.wx-grid .wx-cell) {
|
||||||
|
padding: spacing.$unit * 0.75 spacing.$unit;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.wx-grid .wx-row:hover) {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element color classes
|
||||||
|
:global(.element-fire) { color: #ff6b6b; }
|
||||||
|
:global(.element-water) { color: #4dabf7; }
|
||||||
|
:global(.element-earth) { color: #51cf66; }
|
||||||
|
:global(.element-wind) { color: #69db7c; }
|
||||||
|
:global(.element-light) { color: #ffd43b; }
|
||||||
|
:global(.element-dark) { color: #845ef7; }
|
||||||
|
|
||||||
|
// Database image styling
|
||||||
|
:global(.database-image) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
src/lib/components/database/cells/CharacterImageCell.svelte
Normal file
35
src/lib/components/database/cells/CharacterImageCell.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { IRow } from 'wx-svelte-grid'
|
||||||
|
import { getCharacterImageUrl } from '$lib/utils/database'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: IRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-cell">
|
||||||
|
<img src={getCharacterImageUrl(row.granblue_id)} alt="" class="database-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.image-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 40px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
src/lib/components/database/cells/SummonImageCell.svelte
Normal file
35
src/lib/components/database/cells/SummonImageCell.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { IRow } from 'wx-svelte-grid'
|
||||||
|
import { getSummonImageUrl } from '$lib/utils/database'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: IRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-cell">
|
||||||
|
<img src={getSummonImageUrl(row.granblue_id)} alt="" class="database-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.image-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 40px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
src/lib/components/database/cells/WeaponImageCell.svelte
Normal file
35
src/lib/components/database/cells/WeaponImageCell.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { IRow } from 'wx-svelte-grid'
|
||||||
|
import { getWeaponImageUrl } from '$lib/utils/database'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: IRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-cell">
|
||||||
|
<img src={getWeaponImageUrl(row.granblue_id)} alt="" class="database-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.image-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 40px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/lib/utils/database.ts
Normal file
67
src/lib/utils/database.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { TeamElement } from '$lib/types/enums'
|
||||||
|
|
||||||
|
export function elementLabel(n?: number): string {
|
||||||
|
switch (n) {
|
||||||
|
case TeamElement.Wind:
|
||||||
|
return 'Wind'
|
||||||
|
case TeamElement.Fire:
|
||||||
|
return 'Fire'
|
||||||
|
case TeamElement.Water:
|
||||||
|
return 'Water'
|
||||||
|
case TeamElement.Earth:
|
||||||
|
return 'Earth'
|
||||||
|
case TeamElement.Dark:
|
||||||
|
return 'Dark'
|
||||||
|
case TeamElement.Light:
|
||||||
|
return 'Light'
|
||||||
|
case TeamElement.Null:
|
||||||
|
return 'Null'
|
||||||
|
default:
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementClass(n?: number): string {
|
||||||
|
switch (n) {
|
||||||
|
case TeamElement.Wind:
|
||||||
|
return 'element-wind'
|
||||||
|
case TeamElement.Fire:
|
||||||
|
return 'element-fire'
|
||||||
|
case TeamElement.Water:
|
||||||
|
return 'element-water'
|
||||||
|
case TeamElement.Earth:
|
||||||
|
return 'element-earth'
|
||||||
|
case TeamElement.Dark:
|
||||||
|
return 'element-dark'
|
||||||
|
case TeamElement.Light:
|
||||||
|
return 'element-light'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharacterImageUrl(gbid?: string | number): string {
|
||||||
|
if (!gbid) return '/images/placeholders/placeholder-character-grid.png'
|
||||||
|
return `https://prd-game-a1-granbluefantasy.akamaized.net/assets/img/sp/assets/npc/m/${gbid}_01.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeaponImageUrl(gbid?: string | number): string {
|
||||||
|
if (!gbid) return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
return `https://prd-game-a1-granbluefantasy.akamaized.net/assets/img/sp/assets/weapon/m/${gbid}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSummonImageUrl(gbid?: string | number): string {
|
||||||
|
if (!gbid) return '/images/placeholders/placeholder-summon-main.png'
|
||||||
|
return `https://prd-game-a1-granbluefantasy.akamaized.net/assets/img/sp/assets/summon/m/${gbid}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemName(item: { name?: string | { en?: string; ja?: string } }): string {
|
||||||
|
const name = item.name
|
||||||
|
|
||||||
|
// Handle name object
|
||||||
|
if (!name) return '—'
|
||||||
|
if (typeof name === 'string') return name
|
||||||
|
|
||||||
|
// Handle name.en/name.ja structure (API returns { en: "...", ja: "..." })
|
||||||
|
return name.en || name.ja || '—'
|
||||||
|
}
|
||||||
21
src/routes/database/+layout.server.ts
Normal file
21
src/routes/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, '/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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
43
src/routes/database/+layout.svelte
Normal file
43
src/routes/database/+layout.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { localizeHref } from '$lib/paraglide/runtime'
|
||||||
|
|
||||||
|
const baseHref = localizeHref('/database')
|
||||||
|
const summonsHref = localizeHref('/database/summons')
|
||||||
|
const charactersHref = localizeHref('/database/characters')
|
||||||
|
const weaponsHref = localizeHref('/database/weapons')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="db-nav">
|
||||||
|
<nav>
|
||||||
|
<a href={summonsHref}>Summons</a>
|
||||||
|
<a href={charactersHref}>Characters</a>
|
||||||
|
<a href={weaponsHref}>Weapons</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.db-nav {
|
||||||
|
margin: spacing.$unit 0;
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: spacing.$unit-half;
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--menu-text);
|
||||||
|
background: var(--menu-bg);
|
||||||
|
padding: spacing.$unit (spacing.$unit * 1.5);
|
||||||
|
border-radius: layout.$full-corner;
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
}
|
||||||
|
a:hover { background: var(--menu-bg-item-hover); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
16
src/routes/database/+page.server.ts
Normal file
16
src/routes/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, '/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
7
src/routes/database/+page.ts
Normal file
7
src/routes/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/database/characters/+page.server.ts
Normal file
16
src/routes/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, '/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
127
src/routes/database/characters/+page.svelte
Normal file
127
src/routes/database/characters/+page.svelte
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import DatabaseGrid from '$lib/components/database/DatabaseGrid.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page as pageStore } from '$app/state'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import CharacterImageCell from '$lib/components/database/cells/CharacterImageCell.svelte'
|
||||||
|
import {
|
||||||
|
elementLabel,
|
||||||
|
elementClass
|
||||||
|
} from '$lib/utils/database'
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
console.log('[Characters Page] Received data:', data)
|
||||||
|
console.log('[Characters Page] Items count:', data.items?.length || 0)
|
||||||
|
|
||||||
|
// Convert data to SVAR Grid format - column id must match data property
|
||||||
|
const columns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblue_id',
|
||||||
|
header: 'Image',
|
||||||
|
width: 60,
|
||||||
|
cell: CharacterImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj) => {
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
htmlEnable: true,
|
||||||
|
template: (element) => {
|
||||||
|
const label = elementLabel(element)
|
||||||
|
const className = elementClass(element)
|
||||||
|
return className
|
||||||
|
? `<span class="${className}">${label}</span>`
|
||||||
|
: label
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'max_level',
|
||||||
|
header: 'Max Level',
|
||||||
|
width: 80,
|
||||||
|
sort: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', newPage.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', '1')
|
||||||
|
url.searchParams.set('per_page', newPageSize.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Characters Database</h1>
|
||||||
|
<p class="subtitle">Browse and search all available characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatabaseGrid
|
||||||
|
data={data.items}
|
||||||
|
{columns}
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-page {
|
||||||
|
padding: spacing.$unit * 2;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit * 2;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin-bottom: spacing.$unit * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
src/routes/database/characters/+page.ts
Normal file
18
src/routes/database/characters/+page.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { searchCharacters } from '$lib/api/resources/search'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, url }) => {
|
||||||
|
const page = Number(url.searchParams.get('page') || '1') || 1
|
||||||
|
const pageSize = Number(url.searchParams.get('pageSize') || '20') || 20
|
||||||
|
|
||||||
|
const search = await searchCharacters({ page, per: pageSize }, undefined, fetch)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: search.results || [],
|
||||||
|
page: search.meta?.page || page,
|
||||||
|
totalPages: search.meta?.total_pages || 1,
|
||||||
|
total: search.meta?.count || 0,
|
||||||
|
pageSize: search.meta?.per_page || pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/routes/database/summons/+page.server.ts
Normal file
16
src/routes/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, '/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
127
src/routes/database/summons/+page.svelte
Normal file
127
src/routes/database/summons/+page.svelte
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import DatabaseGrid from '$lib/components/database/DatabaseGrid.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page as pageStore } from '$app/state'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import SummonImageCell from '$lib/components/database/cells/SummonImageCell.svelte'
|
||||||
|
import {
|
||||||
|
elementLabel,
|
||||||
|
elementClass
|
||||||
|
} from '$lib/utils/database'
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
console.log('[Summons Page] Received data:', data)
|
||||||
|
console.log('[Summons Page] Items count:', data.items?.length || 0)
|
||||||
|
|
||||||
|
// Convert data to SVAR Grid format - column id must match data property
|
||||||
|
const columns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblue_id',
|
||||||
|
header: 'Image',
|
||||||
|
width: 60,
|
||||||
|
cell: SummonImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj) => {
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
htmlEnable: true,
|
||||||
|
template: (element) => {
|
||||||
|
const label = elementLabel(element)
|
||||||
|
const className = elementClass(element)
|
||||||
|
return className
|
||||||
|
? `<span class="${className}">${label}</span>`
|
||||||
|
: label
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'max_level',
|
||||||
|
header: 'Max Level',
|
||||||
|
width: 80,
|
||||||
|
sort: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', newPage.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', '1')
|
||||||
|
url.searchParams.set('per_page', newPageSize.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Summons Database</h1>
|
||||||
|
<p class="subtitle">Browse and search all available summons</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatabaseGrid
|
||||||
|
data={data.items}
|
||||||
|
{columns}
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-page {
|
||||||
|
padding: spacing.$unit * 2;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit * 2;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin-bottom: spacing.$unit * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
src/routes/database/summons/+page.ts
Normal file
18
src/routes/database/summons/+page.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { searchSummons } from '$lib/api/resources/search'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, url }) => {
|
||||||
|
const page = Number(url.searchParams.get('page') || '1') || 1
|
||||||
|
const pageSize = Number(url.searchParams.get('pageSize') || '20') || 20
|
||||||
|
|
||||||
|
const search = await searchSummons({ page, per: pageSize }, undefined, fetch)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: search.results || [],
|
||||||
|
page: search.meta?.page || page,
|
||||||
|
totalPages: search.meta?.total_pages || 1,
|
||||||
|
total: search.meta?.count || 0,
|
||||||
|
pageSize: search.meta?.per_page || pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/routes/database/weapons/+page.server.ts
Normal file
16
src/routes/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, '/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = locals.session.account?.role ?? 0
|
||||||
|
if (role < 7) {
|
||||||
|
throw redirect(302, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
131
src/routes/database/weapons/+page.svelte
Normal file
131
src/routes/database/weapons/+page.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import DatabaseGrid from '$lib/components/database/DatabaseGrid.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page as pageStore } from '$app/state'
|
||||||
|
import type { IColumn } from 'wx-svelte-grid'
|
||||||
|
import WeaponImageCell from '$lib/components/database/cells/WeaponImageCell.svelte'
|
||||||
|
import {
|
||||||
|
elementLabel,
|
||||||
|
elementClass
|
||||||
|
} from '$lib/utils/database'
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
console.log('[Weapons Page] Received data:', data)
|
||||||
|
console.log('[Weapons Page] Items count:', data.items?.length || 0)
|
||||||
|
|
||||||
|
// Add console logging to debug
|
||||||
|
console.log('[Weapons Page] Sample item:', data.items?.[0])
|
||||||
|
console.log('[Weapons Page] Sample name:', data.items?.[0]?.name)
|
||||||
|
|
||||||
|
// Convert data to SVAR Grid format - column id must match data property
|
||||||
|
const columns: IColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'granblue_id',
|
||||||
|
header: 'Image',
|
||||||
|
width: 60,
|
||||||
|
cell: WeaponImageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
flexgrow: 1,
|
||||||
|
sort: true,
|
||||||
|
template: (nameObj) => {
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'element',
|
||||||
|
header: 'Element',
|
||||||
|
width: 100,
|
||||||
|
sort: true,
|
||||||
|
htmlEnable: true,
|
||||||
|
template: (element) => {
|
||||||
|
const label = elementLabel(element)
|
||||||
|
const className = elementClass(element)
|
||||||
|
return className
|
||||||
|
? `<span class="${className}">${label}</span>`
|
||||||
|
: label
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'max_level',
|
||||||
|
header: 'Max Level',
|
||||||
|
width: 80,
|
||||||
|
sort: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', newPage.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
|
const url = new URL(pageStore.url)
|
||||||
|
url.searchParams.set('page', '1')
|
||||||
|
url.searchParams.set('per_page', newPageSize.toString())
|
||||||
|
goto(url.toString())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="database-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Weapons Database</h1>
|
||||||
|
<p class="subtitle">Browse and search all available weapons</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatabaseGrid
|
||||||
|
data={data.items}
|
||||||
|
{columns}
|
||||||
|
page={data.page}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
total={data.total}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.database-page {
|
||||||
|
padding: spacing.$unit * 2;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: spacing.$unit * 2;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: typography.$font-xxlarge;
|
||||||
|
font-weight: typography.$bold;
|
||||||
|
margin-bottom: spacing.$unit * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/database/weapons/+page.ts
Normal file
29
src/routes/database/weapons/+page.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { searchWeapons } from '$lib/api/resources/search'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, url }) => {
|
||||||
|
const page = Number(url.searchParams.get('page') || '1') || 1
|
||||||
|
const pageSize = Number(url.searchParams.get('pageSize') || '20') || 20
|
||||||
|
|
||||||
|
console.log('[Database Weapons] Loading page:', page, 'pageSize:', pageSize)
|
||||||
|
|
||||||
|
const search = await searchWeapons({ page, per: pageSize }, undefined, fetch)
|
||||||
|
|
||||||
|
console.log('[Database Weapons] API Response:', search)
|
||||||
|
console.log('[Database Weapons] Meta:', search.meta)
|
||||||
|
console.log('[Database Weapons] Results count:', search.results?.length || 0)
|
||||||
|
|
||||||
|
// Extract data from meta object
|
||||||
|
const result = {
|
||||||
|
items: search.results || [],
|
||||||
|
page: search.meta?.page || page,
|
||||||
|
totalPages: search.meta?.total_pages || 1,
|
||||||
|
total: search.meta?.count || 0,
|
||||||
|
pageSize: search.meta?.per_page || pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Database Weapons] Returning to component:', result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue