add raids/groups database pages with view toggle

This commit is contained in:
Justin Edmund 2025-12-20 01:08:36 -08:00
parent 9f6b95cc3a
commit fd0044211b
10 changed files with 2403 additions and 0 deletions

View 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
View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>