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