add raids/groups database pages with view toggle
This commit is contained in:
parent
9f6b95cc3a
commit
fd0044211b
10 changed files with 2403 additions and 0 deletions
160
src/lib/api/adapters/raid.adapter.ts
Normal file
160
src/lib/api/adapters/raid.adapter.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { BaseAdapter } from './base.adapter'
|
||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||
import type { RequestOptions } from './types'
|
||||
import type {
|
||||
RaidFull,
|
||||
RaidGroupFull,
|
||||
RaidGroupFlat,
|
||||
CreateRaidInput,
|
||||
UpdateRaidInput,
|
||||
CreateRaidGroupInput,
|
||||
UpdateRaidGroupInput,
|
||||
RaidFilters
|
||||
} from '$lib/types/api/raid'
|
||||
import type { Raid, RaidGroup } from '$lib/types/api/entities'
|
||||
|
||||
/**
|
||||
* Adapter for Raid and RaidGroup API operations
|
||||
*/
|
||||
export class RaidAdapter extends BaseAdapter {
|
||||
// ==================== Raid Operations ====================
|
||||
|
||||
/**
|
||||
* Get all raids with optional filtering
|
||||
*/
|
||||
async getAll(filters?: RaidFilters, options?: RequestOptions): Promise<Raid[]> {
|
||||
const queryParams: Record<string, any> = {}
|
||||
|
||||
if (filters) {
|
||||
if (filters.element !== undefined) queryParams.element = filters.element
|
||||
if (filters.groupId) queryParams.group_id = filters.groupId
|
||||
if (filters.difficulty !== undefined) queryParams.difficulty = filters.difficulty
|
||||
if (filters.hl !== undefined) queryParams.hl = filters.hl
|
||||
if (filters.extra !== undefined) queryParams.extra = filters.extra
|
||||
if (filters.guidebooks !== undefined) queryParams.guidebooks = filters.guidebooks
|
||||
}
|
||||
|
||||
const response = await this.request<Raid[]>('/raids', {
|
||||
...options,
|
||||
query: Object.keys(queryParams).length > 0 ? queryParams : undefined
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single raid by slug
|
||||
*/
|
||||
async getBySlug(slug: string, options?: RequestOptions): Promise<RaidFull> {
|
||||
const response = await this.request<RaidFull>(`/raids/${slug}`, options)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new raid (editor only)
|
||||
*/
|
||||
async create(input: CreateRaidInput, options?: RequestOptions): Promise<RaidFull> {
|
||||
const response = await this.request<RaidFull>('/raids', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ raid: input })
|
||||
})
|
||||
this.clearCache('/raids')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a raid (editor only)
|
||||
*/
|
||||
async update(slug: string, input: UpdateRaidInput, options?: RequestOptions): Promise<RaidFull> {
|
||||
const response = await this.request<RaidFull>(`/raids/${slug}`, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ raid: input })
|
||||
})
|
||||
this.clearCache('/raids')
|
||||
this.clearCache(`/raids/${slug}`)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a raid (editor only)
|
||||
*/
|
||||
async delete(slug: string, options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>(`/raids/${slug}`, {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
})
|
||||
this.clearCache('/raids')
|
||||
this.clearCache(`/raids/${slug}`)
|
||||
}
|
||||
|
||||
// ==================== RaidGroup Operations ====================
|
||||
|
||||
/**
|
||||
* Get all raid groups with their raids
|
||||
*/
|
||||
async getGroups(options?: RequestOptions): Promise<RaidGroupFull[]> {
|
||||
const response = await this.request<RaidGroupFull[]>('/raid_groups', options)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single raid group by ID
|
||||
*/
|
||||
async getGroupById(id: string, options?: RequestOptions): Promise<RaidGroupFull> {
|
||||
const response = await this.request<RaidGroupFull>(`/raid_groups/${id}`, options)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new raid group (editor only)
|
||||
*/
|
||||
async createGroup(input: CreateRaidGroupInput, options?: RequestOptions): Promise<RaidGroupFull> {
|
||||
const response = await this.request<RaidGroupFull>('/raid_groups', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ raid_group: input })
|
||||
})
|
||||
this.clearCache('/raid_groups')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a raid group (editor only)
|
||||
*/
|
||||
async updateGroup(id: string, input: UpdateRaidGroupInput, options?: RequestOptions): Promise<RaidGroupFull> {
|
||||
const response = await this.request<RaidGroupFull>(`/raid_groups/${id}`, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ raid_group: input })
|
||||
})
|
||||
this.clearCache('/raid_groups')
|
||||
this.clearCache(`/raid_groups/${id}`)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a raid group (editor only)
|
||||
*/
|
||||
async deleteGroup(id: string, options?: RequestOptions): Promise<void> {
|
||||
await this.request<void>(`/raid_groups/${id}`, {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
})
|
||||
this.clearCache('/raid_groups')
|
||||
this.clearCache(`/raid_groups/${id}`)
|
||||
}
|
||||
|
||||
// ==================== Legacy Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get all raid groups with raids (legacy endpoint)
|
||||
* @deprecated Use getGroups() instead
|
||||
*/
|
||||
async getLegacyGroups(options?: RequestOptions): Promise<RaidGroupFull[]> {
|
||||
const response = await this.request<RaidGroupFull[]>('/raids/groups', options)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export const raidAdapter = new RaidAdapter(DEFAULT_ADAPTER_CONFIG)
|
||||
89
src/lib/types/api/raid.ts
Normal file
89
src/lib/types/api/raid.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Raid and RaidGroup types
|
||||
*
|
||||
* Re-exports base types from entities.ts and adds input types for CRUD operations.
|
||||
*/
|
||||
|
||||
import type { LocalizedName } from './entities'
|
||||
|
||||
// Re-export from entities
|
||||
export type { Raid, RaidGroup } from './entities'
|
||||
|
||||
// Extended Raid type (from :full view)
|
||||
export interface RaidFull {
|
||||
id: string
|
||||
slug: string
|
||||
name: LocalizedName
|
||||
level: number
|
||||
element: number
|
||||
group?: RaidGroupFlat
|
||||
}
|
||||
|
||||
// Flat RaidGroup (from :flat view, used in nested Raid responses)
|
||||
export interface RaidGroupFlat {
|
||||
id: string
|
||||
name: LocalizedName
|
||||
section: number | string
|
||||
order: number
|
||||
difficulty: number
|
||||
hl: boolean
|
||||
extra: boolean
|
||||
guidebooks: boolean
|
||||
}
|
||||
|
||||
// Full RaidGroup (from :full view, includes raids)
|
||||
export interface RaidGroupFull extends RaidGroupFlat {
|
||||
raids: RaidFull[]
|
||||
}
|
||||
|
||||
// Input types for creating/updating raids
|
||||
export interface CreateRaidInput {
|
||||
name_en: string
|
||||
name_jp: string
|
||||
slug: string
|
||||
level: number
|
||||
element: number
|
||||
group_id: string
|
||||
}
|
||||
|
||||
export interface UpdateRaidInput {
|
||||
name_en?: string
|
||||
name_jp?: string
|
||||
slug?: string
|
||||
level?: number
|
||||
element?: number
|
||||
group_id?: string
|
||||
}
|
||||
|
||||
// Input types for creating/updating raid groups
|
||||
export interface CreateRaidGroupInput {
|
||||
name_en: string
|
||||
name_jp: string
|
||||
section: number
|
||||
order: number
|
||||
difficulty: number
|
||||
hl: boolean
|
||||
extra: boolean
|
||||
guidebooks: boolean
|
||||
}
|
||||
|
||||
export interface UpdateRaidGroupInput {
|
||||
name_en?: string
|
||||
name_jp?: string
|
||||
section?: number
|
||||
order?: number
|
||||
difficulty?: number
|
||||
hl?: boolean
|
||||
extra?: boolean
|
||||
guidebooks?: boolean
|
||||
}
|
||||
|
||||
// Filter types for raid queries
|
||||
export interface RaidFilters {
|
||||
element?: number
|
||||
groupId?: string
|
||||
difficulty?: number
|
||||
hl?: boolean
|
||||
extra?: boolean
|
||||
guidebooks?: boolean
|
||||
}
|
||||
315
src/routes/(app)/database/raid-groups/+page.svelte
Normal file
315
src/routes/(app)/database/raid-groups/+page.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import type { RaidGroupFull } from '$lib/types/api/raid'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
// State
|
||||
let searchTerm = $state('')
|
||||
|
||||
// Query for raid groups
|
||||
const groupsQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', 'list'],
|
||||
queryFn: () => raidAdapter.getGroups(),
|
||||
staleTime: 1000 * 60 * 5
|
||||
}))
|
||||
|
||||
// Filter groups
|
||||
const filteredGroups = $derived.by(() => {
|
||||
let groups = groupsQuery.data ?? []
|
||||
|
||||
// Apply text search
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
groups = groups.filter(
|
||||
(g) =>
|
||||
g.name.en?.toLowerCase().includes(term) ||
|
||||
g.name.ja?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// Navigate to group detail
|
||||
function handleRowClick(group: RaidGroupFull) {
|
||||
goto(`/database/raid-groups/${group.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageMeta title="Database - Raid Groups" description="Manage raid groups in the database" />
|
||||
|
||||
<div class="page">
|
||||
<div class="grid">
|
||||
<div class="controls">
|
||||
<input type="text" placeholder="Search groups..." bind:value={searchTerm} />
|
||||
|
||||
<div class="controls-right">
|
||||
<Button variant="primary" size="small" onclick={() => goto('/database/raid-groups/new')}>
|
||||
New Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-wrapper" class:loading={groupsQuery.isLoading}>
|
||||
{#if groupsQuery.isLoading}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<table class="groups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">Name</th>
|
||||
<th class="col-difficulty">Difficulty</th>
|
||||
<th class="col-section">Section</th>
|
||||
<th class="col-order">Order</th>
|
||||
<th class="col-flags">Flags</th>
|
||||
<th class="col-raids">Raids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if filteredGroups.length === 0 && !groupsQuery.isLoading}
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">
|
||||
{searchTerm ? 'No groups match your search' : 'No raid groups yet'}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filteredGroups as group}
|
||||
<tr onclick={() => handleRowClick(group)} class="clickable">
|
||||
<td class="col-name">
|
||||
<span class="group-name">{displayName(group)}</span>
|
||||
</td>
|
||||
<td class="col-difficulty">
|
||||
{group.difficulty ?? '-'}
|
||||
</td>
|
||||
<td class="col-section">
|
||||
{group.section}
|
||||
</td>
|
||||
<td class="col-order">
|
||||
{group.order}
|
||||
</td>
|
||||
<td class="col-flags">
|
||||
<div class="flags">
|
||||
{#if group.hl}
|
||||
<span class="flag hl">HL</span>
|
||||
{/if}
|
||||
{#if group.extra}
|
||||
<span class="flag extra">Extra</span>
|
||||
{/if}
|
||||
{#if group.guidebooks}
|
||||
<span class="flag guidebooks">Guidebooks</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-raids">
|
||||
{group.raids?.length ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<div class="pagination-info">
|
||||
{filteredGroups.length} group{filteredGroups.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
gap: spacing.$unit;
|
||||
|
||||
input {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: var(--input-bound-bg);
|
||||
border: none;
|
||||
border-radius: layout.$item-corner;
|
||||
font-size: typography.$font-medium;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-wrapper {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
min-height: 200px;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: typography.$bold;
|
||||
color: #495057;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.col-name {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.col-difficulty,
|
||||
.col-section,
|
||||
.col-order,
|
||||
.col-raids {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-flags {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: typography.$bold;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: typography.$font-tiny;
|
||||
font-weight: typography.$bold;
|
||||
|
||||
&.hl {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.extra {
|
||||
background: #6f42c1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.guidebooks {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: spacing.$unit-4x !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
src/routes/(app)/database/raid-groups/[id]/+page.svelte
Normal file
213
src/routes/(app)/database/raid-groups/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Get group ID from URL
|
||||
const groupId = $derived($page.params.id)
|
||||
|
||||
// Query for group data
|
||||
const groupQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', groupId],
|
||||
queryFn: () => raidAdapter.getGroupById(groupId ?? ''),
|
||||
enabled: !!groupId
|
||||
}))
|
||||
|
||||
const group = $derived(groupQuery.data)
|
||||
const userRole = $derived(data.role || 0)
|
||||
const canEdit = $derived(userRole >= 7)
|
||||
|
||||
// Navigate to edit
|
||||
function handleEdit() {
|
||||
goto(`/database/raid-groups/${groupId}/edit`)
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
function handleBack() {
|
||||
goto('/database/raid-groups')
|
||||
}
|
||||
|
||||
// Navigate to raid detail
|
||||
function handleRaidClick(slug: string) {
|
||||
goto(`/database/raids/${slug}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if groupQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading raid group...</p>
|
||||
</div>
|
||||
{:else if groupQuery.isError}
|
||||
<div class="error-state">
|
||||
<p>Failed to load raid group</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Groups</Button>
|
||||
</div>
|
||||
{:else if group}
|
||||
<SidebarHeader title={displayName(group)}>
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleBack}>Back</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
{#if canEdit}
|
||||
<Button variant="primary" size="small" onclick={handleEdit}>Edit</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Group Details">
|
||||
<DetailItem label="Name (EN)" value={group.name.en || '-'} />
|
||||
<DetailItem label="Name (JA)" value={group.name.ja || '-'} />
|
||||
<DetailItem label="Section" value={group.section?.toString() ?? '-'} />
|
||||
<DetailItem label="Order" value={group.order?.toString() ?? '-'} />
|
||||
<DetailItem label="Difficulty" value={group.difficulty?.toString() ?? '-'} />
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Flags">
|
||||
<DetailItem label="HL">
|
||||
<span class="badge" class:active={group.hl}>{group.hl ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Extra">
|
||||
<span class="badge" class:active={group.extra}>{group.extra ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Guidebooks">
|
||||
<span class="badge" class:active={group.guidebooks}>{group.guidebooks ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
</DetailsContainer>
|
||||
|
||||
{#if group.raids && group.raids.length > 0}
|
||||
<DetailsContainer title="Member Raids ({group.raids.length})">
|
||||
<div class="raids-list">
|
||||
{#each group.raids as raid}
|
||||
<button class="raid-item" onclick={() => handleRaidClick(raid.slug)}>
|
||||
<span class="raid-name">{displayName(raid)}</span>
|
||||
<span class="raid-level">Lv. {raid.level ?? '-'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailsContainer>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Raid Group Not Found</h2>
|
||||
<p>The raid group you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Groups</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-4x;
|
||||
|
||||
h2 {
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: typography.$font-small;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.raids-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.raid-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: layout.$item-corner;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.raid-name {
|
||||
font-weight: typography.$bold;
|
||||
}
|
||||
|
||||
.raid-level {
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
246
src/routes/(app)/database/raid-groups/[id]/edit/+page.svelte
Normal file
246
src/routes/(app)/database/raid-groups/[id]/edit/+page.svelte
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get group ID from URL
|
||||
const groupId = $derived($page.params.id)
|
||||
|
||||
// Query for group data
|
||||
const groupQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', groupId],
|
||||
queryFn: () => raidAdapter.getGroupById(groupId ?? ''),
|
||||
enabled: !!groupId
|
||||
}))
|
||||
|
||||
const group = $derived(groupQuery.data)
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Edit data state
|
||||
let editData = $state({
|
||||
name_en: '',
|
||||
name_jp: '',
|
||||
section: 1,
|
||||
order: 0,
|
||||
difficulty: 1,
|
||||
hl: false,
|
||||
extra: false,
|
||||
guidebooks: false
|
||||
})
|
||||
|
||||
// Sync edit data when group changes
|
||||
$effect(() => {
|
||||
if (group) {
|
||||
editData = {
|
||||
name_en: group.name.en || '',
|
||||
name_jp: group.name.ja || '',
|
||||
section: typeof group.section === 'string' ? parseInt(group.section) : group.section,
|
||||
order: group.order ?? 0,
|
||||
difficulty: group.difficulty ?? 1,
|
||||
hl: group.hl ?? false,
|
||||
extra: group.extra ?? false,
|
||||
guidebooks: group.guidebooks ?? false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.name_en.trim() !== '' && editData.name_jp.trim() !== ''
|
||||
)
|
||||
|
||||
// Save changes
|
||||
async function handleSave() {
|
||||
if (!canSave || !group || !groupId) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
await raidAdapter.updateGroup(groupId, {
|
||||
name_en: editData.name_en,
|
||||
name_jp: editData.name_jp,
|
||||
section: editData.section,
|
||||
order: editData.order,
|
||||
difficulty: editData.difficulty,
|
||||
hl: editData.hl,
|
||||
extra: editData.extra,
|
||||
guidebooks: editData.guidebooks
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['raid-groups'] })
|
||||
|
||||
// Navigate back to detail page
|
||||
goto(`/database/raid-groups/${groupId}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to save raid group'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto(`/database/raid-groups/${groupId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if groupQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading raid group...</p>
|
||||
</div>
|
||||
{:else if group}
|
||||
<SidebarHeader title="Edit Raid Group">
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
<Button variant="primary" size="small" onclick={handleSave} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
{#if saveError}
|
||||
<div class="error-banner">{saveError}</div>
|
||||
{/if}
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Group Details">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.name_en}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="English name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Name (JA)"
|
||||
bind:value={editData.name_jp}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Japanese name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Section"
|
||||
bind:value={editData.section}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Order"
|
||||
bind:value={editData.order}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Difficulty"
|
||||
bind:value={editData.difficulty}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Flags">
|
||||
<DetailItem
|
||||
label="HL"
|
||||
bind:value={editData.hl}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Extra"
|
||||
bind:value={editData.extra}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Guidebooks"
|
||||
bind:value={editData.guidebooks}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Raid Group Not Found</h2>
|
||||
<p>The raid group you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={() => goto('/database/raid-groups')}>
|
||||
Back to Groups
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-4x;
|
||||
|
||||
h2 {
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
padding: spacing.$unit-2x;
|
||||
background: colors.$error--bg--light;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
179
src/routes/(app)/database/raid-groups/new/+page.svelte
Normal file
179
src/routes/(app)/database/raid-groups/new/+page.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { useQueryClient } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Edit data state
|
||||
let editData = $state({
|
||||
name_en: '',
|
||||
name_jp: '',
|
||||
section: 1,
|
||||
order: 0,
|
||||
difficulty: 1,
|
||||
hl: false,
|
||||
extra: false,
|
||||
guidebooks: false
|
||||
})
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.name_en.trim() !== '' && editData.name_jp.trim() !== ''
|
||||
)
|
||||
|
||||
// Create group
|
||||
async function handleSave() {
|
||||
if (!canSave) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
const newGroup = await raidAdapter.createGroup({
|
||||
name_en: editData.name_en,
|
||||
name_jp: editData.name_jp,
|
||||
section: editData.section,
|
||||
order: editData.order,
|
||||
difficulty: editData.difficulty,
|
||||
hl: editData.hl,
|
||||
extra: editData.extra,
|
||||
guidebooks: editData.guidebooks
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['raid-groups'] })
|
||||
|
||||
// Navigate to the new group's detail page
|
||||
goto(`/database/raid-groups/${newGroup.id}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to create raid group'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto('/database/raid-groups')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SidebarHeader title="New Raid Group">
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
<Button variant="primary" size="small" onclick={handleSave} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
{#if saveError}
|
||||
<div class="error-banner">{saveError}</div>
|
||||
{/if}
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Group Details">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.name_en}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="English name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Name (JA)"
|
||||
bind:value={editData.name_jp}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Japanese name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Section"
|
||||
bind:value={editData.section}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Order"
|
||||
bind:value={editData.order}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Difficulty"
|
||||
bind:value={editData.difficulty}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Flags">
|
||||
<DetailItem
|
||||
label="HL"
|
||||
bind:value={editData.hl}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Extra"
|
||||
bind:value={editData.extra}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Guidebooks"
|
||||
bind:value={editData.guidebooks}
|
||||
editable={true}
|
||||
type="checkbox"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
padding: spacing.$unit-2x;
|
||||
background: colors.$error--bg--light;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
529
src/routes/(app)/database/raids/+page.svelte
Normal file
529
src/routes/(app)/database/raids/+page.svelte
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import PageMeta from '$lib/components/PageMeta.svelte'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import ElementBadge from '$lib/components/ui/ElementBadge.svelte'
|
||||
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
|
||||
import Select from '$lib/components/ui/Select.svelte'
|
||||
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||
import type { Raid, RaidGroup } from '$lib/types/api/entities'
|
||||
import type { RaidGroupFull } from '$lib/types/api/raid'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
// State
|
||||
let viewMode = $state<'raids' | 'groups'>('raids')
|
||||
let searchTerm = $state('')
|
||||
let elementFilters = $state<number[]>([])
|
||||
let groupFilter = $state<string | undefined>(undefined)
|
||||
let hlFilter = $state<number | undefined>(undefined)
|
||||
let extraFilter = $state<number | undefined>(undefined)
|
||||
|
||||
// Query for raids
|
||||
const raidsQuery = createQuery(() => ({
|
||||
queryKey: ['raids', 'list'],
|
||||
queryFn: () => raidAdapter.getAll(),
|
||||
staleTime: 1000 * 60 * 5
|
||||
}))
|
||||
|
||||
// Query for raid groups (for filter dropdown)
|
||||
const groupsQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', 'list'],
|
||||
queryFn: () => raidAdapter.getGroups(),
|
||||
staleTime: 1000 * 60 * 10
|
||||
}))
|
||||
|
||||
// Build group options for Select
|
||||
const groupOptions = $derived(
|
||||
(groupsQuery.data ?? []).map((g) => ({
|
||||
value: g.id,
|
||||
label: displayName(g)
|
||||
}))
|
||||
)
|
||||
|
||||
// Filter raids
|
||||
const filteredRaids = $derived.by(() => {
|
||||
let raids = raidsQuery.data ?? []
|
||||
|
||||
// Apply text search
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
raids = raids.filter(
|
||||
(r) =>
|
||||
r.name.en?.toLowerCase().includes(term) ||
|
||||
r.name.ja?.toLowerCase().includes(term) ||
|
||||
r.slug?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply element filter (multi-select)
|
||||
if (elementFilters.length > 0) {
|
||||
raids = raids.filter((r) => r.element !== undefined && elementFilters.includes(r.element))
|
||||
}
|
||||
|
||||
// Apply group filter
|
||||
if (groupFilter) {
|
||||
raids = raids.filter((r) => r.group?.id === groupFilter)
|
||||
}
|
||||
|
||||
// Apply HL filter (1 = yes, 0 = no)
|
||||
if (hlFilter !== undefined) {
|
||||
const hlBool = hlFilter === 1
|
||||
raids = raids.filter((r) => r.group?.hl === hlBool)
|
||||
}
|
||||
|
||||
// Apply Extra filter (1 = yes, 0 = no)
|
||||
if (extraFilter !== undefined) {
|
||||
const extraBool = extraFilter === 1
|
||||
raids = raids.filter((r) => r.group?.extra === extraBool)
|
||||
}
|
||||
|
||||
return raids
|
||||
})
|
||||
|
||||
// Navigate to raid detail
|
||||
function handleRaidClick(raid: Raid) {
|
||||
goto(`/database/raids/${raid.slug}`)
|
||||
}
|
||||
|
||||
// Navigate to raid group detail
|
||||
function handleGroupClick(group: RaidGroupFull) {
|
||||
goto(`/database/raid-groups/${group.id}`)
|
||||
}
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = $derived(
|
||||
elementFilters.length > 0 ||
|
||||
groupFilter !== undefined ||
|
||||
hlFilter !== undefined ||
|
||||
extraFilter !== undefined
|
||||
)
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
elementFilters = []
|
||||
groupFilter = undefined
|
||||
hlFilter = undefined
|
||||
extraFilter = undefined
|
||||
}
|
||||
|
||||
// Element options (matching CollectionFilters)
|
||||
const elements = [
|
||||
{ value: 0, label: 'Null', color: '#888' },
|
||||
{ value: 1, label: 'Wind', color: '#4A9B3F' },
|
||||
{ value: 2, label: 'Fire', color: '#D94444' },
|
||||
{ value: 3, label: 'Water', color: '#4A7FB8' },
|
||||
{ value: 4, label: 'Earth', color: '#9B6E3F' },
|
||||
{ value: 5, label: 'Dark', color: '#6B3E9B' },
|
||||
{ value: 6, label: 'Light', color: '#F4B643' }
|
||||
]
|
||||
|
||||
// Boolean filter options
|
||||
const booleanOptions = [
|
||||
{ value: 1, label: 'Yes' },
|
||||
{ value: 0, label: 'No' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<PageMeta title="Database - Raids" description="Manage raids in the database" />
|
||||
|
||||
<div class="page">
|
||||
<div class="grid">
|
||||
<div class="controls">
|
||||
<SegmentedControl bind:value={viewMode} size="xsmall" variant="background">
|
||||
<Segment value="raids">Raids</Segment>
|
||||
<Segment value="groups">Groups</Segment>
|
||||
</SegmentedControl>
|
||||
|
||||
{#if viewMode === 'raids'}
|
||||
<div class="filters">
|
||||
<MultiSelect
|
||||
options={elements}
|
||||
bind:value={elementFilters}
|
||||
placeholder="Element"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Select
|
||||
options={groupOptions}
|
||||
bind:value={groupFilter}
|
||||
placeholder="Raid Group"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Select
|
||||
options={booleanOptions}
|
||||
bind:value={hlFilter}
|
||||
placeholder="HL"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Select
|
||||
options={booleanOptions}
|
||||
bind:value={extraFilter}
|
||||
placeholder="Extra"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{#if hasActiveFilters}
|
||||
<button type="button" class="clear-btn" onclick={clearFilters}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<input type="text" placeholder="Search..." bind:value={searchTerm} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if viewMode === 'raids'}
|
||||
<div class="grid-wrapper" class:loading={raidsQuery.isLoading}>
|
||||
{#if raidsQuery.isLoading}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<table class="raids-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">Name</th>
|
||||
<th class="col-level">Level</th>
|
||||
<th class="col-element">Element</th>
|
||||
<th class="col-group">Group</th>
|
||||
<th class="col-slug">Slug</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if filteredRaids.length === 0 && !raidsQuery.isLoading}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No raids match your filters'
|
||||
: 'No raids yet'}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filteredRaids as raid}
|
||||
<tr onclick={() => handleRaidClick(raid)} class="clickable">
|
||||
<td class="col-name">
|
||||
<span class="raid-name">{displayName(raid)}</span>
|
||||
</td>
|
||||
<td class="col-level">
|
||||
{raid.level ?? '-'}
|
||||
</td>
|
||||
<td class="col-element">
|
||||
{#if raid.element !== undefined && raid.element !== null}
|
||||
<ElementBadge element={raid.element} />
|
||||
{:else}
|
||||
<span class="no-element">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="col-group">
|
||||
{raid.group ? displayName(raid.group) : '-'}
|
||||
</td>
|
||||
<td class="col-slug">
|
||||
<code>{raid.slug}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<div class="pagination-info">
|
||||
{filteredRaids.length} raid{filteredRaids.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid-wrapper" class:loading={groupsQuery.isLoading}>
|
||||
{#if groupsQuery.isLoading}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<table class="raids-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">Name</th>
|
||||
<th class="col-section">Section</th>
|
||||
<th class="col-difficulty">Difficulty</th>
|
||||
<th class="col-flags">Flags</th>
|
||||
<th class="col-raids">Raids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if (groupsQuery.data ?? []).length === 0 && !groupsQuery.isLoading}
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">No raid groups yet</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each (groupsQuery.data ?? []) as group}
|
||||
<tr onclick={() => handleGroupClick(group)} class="clickable">
|
||||
<td class="col-name">
|
||||
<span class="raid-name">{displayName(group)}</span>
|
||||
</td>
|
||||
<td class="col-section">
|
||||
{group.section ?? '-'}
|
||||
</td>
|
||||
<td class="col-difficulty">
|
||||
{group.difficulty ?? '-'}
|
||||
</td>
|
||||
<td class="col-flags">
|
||||
<div class="flags">
|
||||
{#if group.hl}<span class="flag">HL</span>{/if}
|
||||
{#if group.extra}<span class="flag">Extra</span>{/if}
|
||||
{#if group.guidebooks}<span class="flag">Guidebooks</span>{/if}
|
||||
{#if !group.hl && !group.extra && !group.guidebooks}
|
||||
<span class="no-flags">-</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-raids">
|
||||
{group.raids?.length ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<div class="pagination-info">
|
||||
{(groupsQuery.data ?? []).length} group{(groupsQuery.data ?? []).length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: spacing.$unit;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
gap: spacing.$unit;
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: var(--input-bound-bg);
|
||||
border: none;
|
||||
border-radius: layout.$item-corner;
|
||||
font-family: 'AGrot', system-ui, sans-serif;
|
||||
font-size: typography.$font-small;
|
||||
width: 200px;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: spacing.$unit-half spacing.$unit;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-wrapper {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
min-height: 200px;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.raids-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: typography.$bold;
|
||||
color: #495057;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.col-name {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.col-level {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.col-element {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.col-group {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.col-slug {
|
||||
min-width: 150px;
|
||||
|
||||
code {
|
||||
font-size: typography.$font-small;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.col-section,
|
||||
.col-difficulty,
|
||||
.col-raids {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.col-flags {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.raid-name {
|
||||
font-weight: typography.$bold;
|
||||
}
|
||||
|
||||
.no-element,
|
||||
.no-flags {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: typography.$font-tiny;
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: spacing.$unit-4x !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
207
src/routes/(app)/database/raids/[slug]/+page.svelte
Normal file
207
src/routes/(app)/database/raids/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import ElementBadge from '$lib/components/ui/ElementBadge.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Get raid slug from URL
|
||||
const raidSlug = $derived($page.params.slug)
|
||||
|
||||
// Query for raid data
|
||||
const raidQuery = createQuery(() => ({
|
||||
queryKey: ['raids', raidSlug],
|
||||
queryFn: () => raidAdapter.getBySlug(raidSlug ?? ''),
|
||||
enabled: !!raidSlug
|
||||
}))
|
||||
|
||||
const raid = $derived(raidQuery.data)
|
||||
const userRole = $derived(data.role || 0)
|
||||
const canEdit = $derived(userRole >= 7)
|
||||
|
||||
// Navigate to edit
|
||||
function handleEdit() {
|
||||
goto(`/database/raids/${raidSlug}/edit`)
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
function handleBack() {
|
||||
goto('/database/raids')
|
||||
}
|
||||
|
||||
// Navigate to group detail
|
||||
function handleGroupClick() {
|
||||
if (raid?.group?.id) {
|
||||
goto(`/database/raid-groups/${raid.group.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if raidQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading raid...</p>
|
||||
</div>
|
||||
{:else if raidQuery.isError}
|
||||
<div class="error-state">
|
||||
<p>Failed to load raid</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Raids</Button>
|
||||
</div>
|
||||
{:else if raid}
|
||||
<SidebarHeader title={displayName(raid)}>
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleBack}>Back</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
{#if canEdit}
|
||||
<Button variant="primary" size="small" onclick={handleEdit}>Edit</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Raid Details">
|
||||
<DetailItem label="Name (EN)" value={raid.name.en || '-'} />
|
||||
<DetailItem label="Name (JA)" value={raid.name.ja || '-'} />
|
||||
<DetailItem label="Slug" value={raid.slug || '-'} />
|
||||
<DetailItem label="Level" value={raid.level?.toString() ?? '-'} />
|
||||
<DetailItem label="Element">
|
||||
{#if raid.element !== undefined && raid.element !== null}
|
||||
<ElementBadge element={raid.element} />
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Classification">
|
||||
<DetailItem label="Group">
|
||||
{#if raid.group}
|
||||
<button class="group-link" onclick={handleGroupClick}>
|
||||
{displayName(raid.group)}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="no-value">-</span>
|
||||
{/if}
|
||||
</DetailItem>
|
||||
{#if raid.group}
|
||||
<DetailItem label="Difficulty" value={raid.group.difficulty?.toString() ?? '-'} />
|
||||
<DetailItem label="HL">
|
||||
<span class="badge" class:active={raid.group.hl}>{raid.group.hl ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Extra">
|
||||
<span class="badge" class:active={raid.group.extra}>{raid.group.extra ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Guidebooks">
|
||||
<span class="badge" class:active={raid.group.guidebooks}>{raid.group.guidebooks ? 'Yes' : 'No'}</span>
|
||||
</DetailItem>
|
||||
{/if}
|
||||
</DetailsContainer>
|
||||
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Raid Not Found</h2>
|
||||
<p>The raid you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Raids</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-4x;
|
||||
|
||||
h2 {
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.group-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link-color, #007bff);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--link-hover-color, #0056b3);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: typography.$font-small;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
265
src/routes/(app)/database/raids/[slug]/edit/+page.svelte
Normal file
265
src/routes/(app)/database/raids/[slug]/edit/+page.svelte
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get raid slug from URL
|
||||
const raidSlug = $derived($page.params.slug)
|
||||
|
||||
// Query for raid data
|
||||
const raidQuery = createQuery(() => ({
|
||||
queryKey: ['raids', raidSlug],
|
||||
queryFn: () => raidAdapter.getBySlug(raidSlug ?? ''),
|
||||
enabled: !!raidSlug
|
||||
}))
|
||||
|
||||
// Query for raid groups (for dropdown)
|
||||
const groupsQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', 'list'],
|
||||
queryFn: () => raidAdapter.getGroups(),
|
||||
staleTime: 1000 * 60 * 10
|
||||
}))
|
||||
|
||||
const raid = $derived(raidQuery.data)
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Edit data state
|
||||
let editData = $state({
|
||||
name_en: '',
|
||||
name_jp: '',
|
||||
slug: '',
|
||||
level: 0,
|
||||
element: 0,
|
||||
group_id: ''
|
||||
})
|
||||
|
||||
// Sync edit data when raid changes
|
||||
$effect(() => {
|
||||
if (raid) {
|
||||
editData = {
|
||||
name_en: raid.name.en || '',
|
||||
name_jp: raid.name.ja || '',
|
||||
slug: raid.slug || '',
|
||||
level: raid.level ?? 0,
|
||||
element: raid.element ?? 0,
|
||||
group_id: raid.group?.id || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Element options
|
||||
const elementOptions = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Fire' },
|
||||
{ value: 2, label: 'Water' },
|
||||
{ value: 3, label: 'Earth' },
|
||||
{ value: 4, label: 'Wind' },
|
||||
{ value: 5, label: 'Light' },
|
||||
{ value: 6, label: 'Dark' }
|
||||
]
|
||||
|
||||
// Group options derived from query
|
||||
const groupOptions = $derived(
|
||||
(groupsQuery.data ?? []).map((g) => ({
|
||||
value: g.id,
|
||||
label: displayName(g)
|
||||
}))
|
||||
)
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.name_en.trim() !== '' && editData.slug.trim() !== '' && editData.group_id !== ''
|
||||
)
|
||||
|
||||
// Save changes
|
||||
async function handleSave() {
|
||||
if (!canSave || !raid || !raidSlug) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
const result = await raidAdapter.update(raidSlug, {
|
||||
name_en: editData.name_en,
|
||||
name_jp: editData.name_jp,
|
||||
slug: editData.slug,
|
||||
level: editData.level,
|
||||
element: editData.element,
|
||||
group_id: editData.group_id
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['raids'] })
|
||||
|
||||
// Navigate to the new slug if it changed
|
||||
goto(`/database/raids/${result.slug}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to save raid'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto(`/database/raids/${raidSlug}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if raidQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading raid...</p>
|
||||
</div>
|
||||
{:else if raid}
|
||||
<SidebarHeader title="Edit Raid">
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
<Button variant="primary" size="small" onclick={handleSave} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
{#if saveError}
|
||||
<div class="error-banner">{saveError}</div>
|
||||
{/if}
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Raid Details">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.name_en}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="English name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Name (JA)"
|
||||
bind:value={editData.name_jp}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Japanese name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Slug"
|
||||
bind:value={editData.slug}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="url-friendly-slug"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Level"
|
||||
bind:value={editData.level}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Element"
|
||||
bind:value={editData.element}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={elementOptions}
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Classification">
|
||||
<DetailItem
|
||||
label="Raid Group"
|
||||
bind:value={editData.group_id}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={groupOptions}
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Raid Not Found</h2>
|
||||
<p>The raid you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={() => goto('/database/raids')}>
|
||||
Back to Raids
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
gap: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-4x;
|
||||
|
||||
h2 {
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
padding: spacing.$unit-2x;
|
||||
background: colors.$error--bg--light;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
200
src/routes/(app)/database/raids/new/+page.svelte
Normal file
200
src/routes/(app)/database/raids/new/+page.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
|
||||
import DetailItem from '$lib/components/ui/DetailItem.svelte'
|
||||
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Query for raid groups (for dropdown)
|
||||
const groupsQuery = createQuery(() => ({
|
||||
queryKey: ['raid-groups', 'list'],
|
||||
queryFn: () => raidAdapter.getGroups(),
|
||||
staleTime: 1000 * 60 * 10
|
||||
}))
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Edit data state
|
||||
let editData = $state({
|
||||
name_en: '',
|
||||
name_jp: '',
|
||||
slug: '',
|
||||
level: 0,
|
||||
element: 0,
|
||||
group_id: ''
|
||||
})
|
||||
|
||||
// Element options
|
||||
const elementOptions = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Fire' },
|
||||
{ value: 2, label: 'Water' },
|
||||
{ value: 3, label: 'Earth' },
|
||||
{ value: 4, label: 'Wind' },
|
||||
{ value: 5, label: 'Light' },
|
||||
{ value: 6, label: 'Dark' }
|
||||
]
|
||||
|
||||
// Group options derived from query
|
||||
const groupOptions = $derived(
|
||||
(groupsQuery.data ?? []).map((g) => ({
|
||||
value: g.id,
|
||||
label: displayName(g)
|
||||
}))
|
||||
)
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.name_en.trim() !== '' && editData.slug.trim() !== '' && editData.group_id !== ''
|
||||
)
|
||||
|
||||
// Create raid
|
||||
async function handleSave() {
|
||||
if (!canSave) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
const newRaid = await raidAdapter.create({
|
||||
name_en: editData.name_en,
|
||||
name_jp: editData.name_jp,
|
||||
slug: editData.slug,
|
||||
level: editData.level,
|
||||
element: editData.element,
|
||||
group_id: editData.group_id
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['raids'] })
|
||||
|
||||
// Navigate to the new raid's detail page
|
||||
goto(`/database/raids/${newRaid.slug}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to create raid'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto('/database/raids')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SidebarHeader title="New Raid">
|
||||
{#snippet leftAccessory()}
|
||||
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
|
||||
{/snippet}
|
||||
{#snippet rightAccessory()}
|
||||
<Button variant="primary" size="small" onclick={handleSave} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SidebarHeader>
|
||||
|
||||
{#if saveError}
|
||||
<div class="error-banner">{saveError}</div>
|
||||
{/if}
|
||||
|
||||
<section class="details">
|
||||
<DetailsContainer title="Raid Details">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.name_en}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="English name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Name (JA)"
|
||||
bind:value={editData.name_jp}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Japanese name"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Slug"
|
||||
bind:value={editData.slug}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="url-friendly-slug"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Level"
|
||||
bind:value={editData.level}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Element"
|
||||
bind:value={editData.element}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={elementOptions}
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<DetailsContainer title="Classification">
|
||||
<DetailItem
|
||||
label="Raid Group"
|
||||
bind:value={editData.group_id}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={groupOptions}
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/effects' as effects;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: layout.$page-corner;
|
||||
box-shadow: effects.$page-elevation;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
color: colors.$error;
|
||||
font-size: typography.$font-small;
|
||||
padding: spacing.$unit-2x;
|
||||
background: colors.$error--bg--light;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue