add crew pages: dashboard, create, join, settings, gw events admin
This commit is contained in:
parent
aee0690b2d
commit
eaea344db4
10 changed files with 2919 additions and 0 deletions
14
src/routes/(app)/crew/+layout.server.ts
Normal file
14
src/routes/(app)/crew/+layout.server.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { redirect } from '@sveltejs/kit'
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
// Check authentication first
|
||||
if (!locals.session.isAuthenticated) {
|
||||
throw redirect(302, '/auth/login')
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.session.user,
|
||||
account: locals.session.account
|
||||
}
|
||||
}
|
||||
41
src/routes/(app)/crew/+layout.svelte
Normal file
41
src/routes/(app)/crew/+layout.svelte
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import type { LayoutData } from './$types'
|
||||
|
||||
const { data, children }: { data: LayoutData; children: () => any } = $props()
|
||||
|
||||
// Query for the user's crew
|
||||
const crewQuery = createQuery(() => crewQueries.myCrew())
|
||||
|
||||
// Update crew store when query data changes
|
||||
$effect(() => {
|
||||
if (crewQuery.data) {
|
||||
crewStore.setCrew(crewQuery.data, crewQuery.data.currentMembership ?? null)
|
||||
} else if (crewQuery.isError) {
|
||||
crewStore.clear()
|
||||
}
|
||||
})
|
||||
|
||||
// Sync loading state
|
||||
$effect(() => {
|
||||
crewStore.setLoading(crewQuery.isLoading)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="crew-layout">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
|
||||
.crew-layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
</style>
|
||||
770
src/routes/(app)/crew/+page.svelte
Normal file
770
src/routes/(app)/crew/+page.svelte
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
import { useCreateCrew, useUpdateCrew } from '$lib/api/mutations/crew.mutations'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
|
||||
import ModalBody from '$lib/components/ui/ModalBody.svelte'
|
||||
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||
import Input from '$lib/components/ui/Input.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Query for the user's crew
|
||||
const crewQuery = createQuery(() => crewQueries.myCrew())
|
||||
|
||||
// Query for pending invitations (shown when no crew)
|
||||
const invitationsQuery = createQuery(() => crewQueries.pendingInvitations())
|
||||
|
||||
// Query for GW events (only when in crew)
|
||||
const eventsQuery = createQuery(() => ({
|
||||
queryKey: ['gw', 'events'],
|
||||
queryFn: () => gwAdapter.getEvents(),
|
||||
enabled: crewStore.isInCrew,
|
||||
staleTime: 1000 * 60 * 5
|
||||
}))
|
||||
|
||||
// Element labels (matches GranblueEnums::ELEMENTS)
|
||||
const elementLabels: Record<number, string> = {
|
||||
0: 'Null',
|
||||
1: 'Wind',
|
||||
2: 'Fire',
|
||||
3: 'Water',
|
||||
4: 'Earth',
|
||||
5: 'Dark',
|
||||
6: 'Light'
|
||||
}
|
||||
|
||||
// Element colors for badges
|
||||
const elementColors: Record<number, string> = {
|
||||
0: 'null',
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
|
||||
// Mutations
|
||||
const createCrewMutation = useCreateCrew()
|
||||
const updateCrewMutation = useUpdateCrew()
|
||||
|
||||
// Modal state
|
||||
let createModalOpen = $state(false)
|
||||
let settingsModalOpen = $state(false)
|
||||
|
||||
// Create form state
|
||||
let crewName = $state('')
|
||||
let crewGamertag = $state('')
|
||||
let crewDescription = $state('')
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
// Settings form state
|
||||
let settingsName = $state('')
|
||||
let settingsGamertag = $state('')
|
||||
let settingsDescription = $state('')
|
||||
let settingsError = $state<string | null>(null)
|
||||
|
||||
// Sync settings form when modal opens
|
||||
function openSettingsModal() {
|
||||
settingsName = crewStore.crew?.name ?? ''
|
||||
settingsGamertag = crewStore.crew?.gamertag ?? ''
|
||||
settingsDescription = crewStore.crew?.description ?? ''
|
||||
settingsError = null
|
||||
settingsModalOpen = true
|
||||
}
|
||||
|
||||
// Validation
|
||||
const canCreate = $derived(crewName.trim().length > 0)
|
||||
const canSaveSettings = $derived(settingsName.trim().length > 0)
|
||||
|
||||
// Handle create crew
|
||||
async function handleCreateCrew() {
|
||||
if (!canCreate) return
|
||||
|
||||
error = null
|
||||
|
||||
try {
|
||||
const crew = await createCrewMutation.mutateAsync({
|
||||
name: crewName.trim(),
|
||||
gamertag: crewGamertag.trim() || undefined,
|
||||
description: crewDescription.trim() || undefined
|
||||
})
|
||||
|
||||
// Update the store - creator is always captain
|
||||
crewStore.setCrew(crew, {
|
||||
id: '', // Will be populated when fetching crew
|
||||
role: 'captain',
|
||||
retired: false,
|
||||
retiredAt: null,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Close modal and reset form
|
||||
createModalOpen = false
|
||||
crewName = ''
|
||||
crewGamertag = ''
|
||||
crewDescription = ''
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create crew'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
createModalOpen = false
|
||||
error = null
|
||||
}
|
||||
|
||||
// Handle update crew settings
|
||||
async function handleUpdateSettings() {
|
||||
if (!canSaveSettings) return
|
||||
|
||||
settingsError = null
|
||||
|
||||
try {
|
||||
const crew = await updateCrewMutation.mutateAsync({
|
||||
name: settingsName.trim(),
|
||||
gamertag: settingsGamertag.trim() || undefined,
|
||||
description: settingsDescription.trim() || undefined
|
||||
})
|
||||
|
||||
// Update the store
|
||||
crewStore.setCrew(crew, crewStore.membership)
|
||||
|
||||
// Close modal
|
||||
settingsModalOpen = false
|
||||
} catch (err: any) {
|
||||
settingsError = err.message || 'Failed to update crew'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseSettingsModal() {
|
||||
settingsModalOpen = false
|
||||
settingsError = null
|
||||
}
|
||||
|
||||
// Helper for formatting dates
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function formatEventStatus(status: string, startDate: string): string {
|
||||
if (status === 'upcoming') {
|
||||
const now = new Date()
|
||||
const start = new Date(startDate)
|
||||
const diffTime = start.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
if (diffDays <= 0) return 'Starting soon'
|
||||
if (diffDays === 1) return 'in 1 day'
|
||||
return `in ${diffDays} days`
|
||||
}
|
||||
return status
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crew | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
{#if crewQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if crewQuery.isError || !crewStore.isInCrew}
|
||||
<!-- No crew state -->
|
||||
<div class="no-crew">
|
||||
<div class="no-crew-content">
|
||||
<p class="description">
|
||||
Crews let you team up with other players, track Guild War scores, and share strategies.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<Button variant="primary" size="small" onclick={() => (createModalOpen = true)}>
|
||||
Create a Crew
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if invitationsQuery.data && invitationsQuery.data.length > 0}
|
||||
<div class="invitations-section">
|
||||
<ul class="invitation-list">
|
||||
{#each invitationsQuery.data as invitation}
|
||||
{#if invitation.crew && invitation.invitedBy}
|
||||
<li class="invitation-item">
|
||||
<div class="invitation-info">
|
||||
<span class="crew-name">{invitation.crew.name}</span>
|
||||
<span class="invited-by">
|
||||
from {invitation.invitedBy.username}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onclick={() => goto(`/crew/join?invitation=${invitation.id}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Has crew - show dashboard -->
|
||||
<div class="crew-dashboard">
|
||||
<CrewHeader
|
||||
title={crewStore.crew?.name ?? ''}
|
||||
subtitle={crewStore.crew?.gamertag}
|
||||
description={crewStore.crew?.description}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if crewStore.isOfficer}
|
||||
<Button variant="secondary" size="small" onclick={openSettingsModal}>Settings</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CrewHeader>
|
||||
|
||||
<div class="stats-row">
|
||||
<a href="/crew/members" class="stat stat-link">
|
||||
<span class="stat-value">{crewStore.crew?.memberCount ?? 0}</span>
|
||||
<span class="stat-label">Members</span>
|
||||
</a>
|
||||
<div class="stat">
|
||||
<span
|
||||
class="stat-value role"
|
||||
class:captain={crewStore.isCaptain}
|
||||
class:officer={crewStore.isViceCaptain}
|
||||
>
|
||||
{#if crewStore.isCaptain}
|
||||
Captain
|
||||
{:else if crewStore.isViceCaptain}
|
||||
Vice Captain
|
||||
{:else}
|
||||
Member
|
||||
{/if}
|
||||
</span>
|
||||
<span class="stat-label">Your Role</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GW Events Section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Unite and Fight</span>
|
||||
</div>
|
||||
|
||||
{#if eventsQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading events...</p>
|
||||
</div>
|
||||
{:else if eventsQuery.data && eventsQuery.data.length > 0}
|
||||
<ul class="event-list">
|
||||
{#each eventsQuery.data as event}
|
||||
<li class="event-item" onclick={() => goto(`/crew/events/${event.eventNumber}`)}>
|
||||
<div class="event-info">
|
||||
<span class="event-number">{event.eventNumber}</span>
|
||||
<span class="element-badge element-{elementColors[event.element]}">
|
||||
{elementLabels[event.element] ?? 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="event-dates">
|
||||
{formatDate(event.startDate)} – {formatDate(event.endDate)}
|
||||
</span>
|
||||
<span class="event-status status-{event.status}"
|
||||
>{formatEventStatus(event.status, event.startDate)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty-state">No events yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Crew Modal -->
|
||||
<Dialog bind:open={createModalOpen} onOpenChange={(open) => !open && handleCloseModal()}>
|
||||
{#snippet children()}
|
||||
<ModalHeader title="Create a Crew" />
|
||||
|
||||
<ModalBody>
|
||||
{#snippet children()}
|
||||
<div class="modal-form">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-fields">
|
||||
<Input
|
||||
label="Crew Name"
|
||||
bind:value={crewName}
|
||||
placeholder="Enter crew name"
|
||||
maxLength={100}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Gamertag (optional)"
|
||||
bind:value={crewGamertag}
|
||||
placeholder="Short tag, e.g. CREW"
|
||||
maxLength={5}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="crew-description"
|
||||
>Description <span class="optional">(optional)</span></label
|
||||
>
|
||||
<textarea
|
||||
id="crew-description"
|
||||
bind:value={crewDescription}
|
||||
placeholder="Tell others about your crew"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#snippet children()}
|
||||
<Button variant="ghost" onclick={handleCloseModal} disabled={createCrewMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleCreateCrew}
|
||||
variant="primary"
|
||||
disabled={!canCreate || createCrewMutation.isPending}
|
||||
>
|
||||
{createCrewMutation.isPending ? 'Creating...' : 'Create Crew'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<!-- Crew Settings Modal -->
|
||||
<Dialog bind:open={settingsModalOpen} onOpenChange={(open) => !open && handleCloseSettingsModal()}>
|
||||
{#snippet children()}
|
||||
<ModalHeader title="Crew Settings" />
|
||||
|
||||
<ModalBody>
|
||||
{#snippet children()}
|
||||
<div class="modal-form">
|
||||
{#if settingsError}
|
||||
<div class="error-message">{settingsError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-fields">
|
||||
<Input
|
||||
label="Crew Name"
|
||||
bind:value={settingsName}
|
||||
placeholder="Enter crew name"
|
||||
maxLength={100}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Gamertag (optional)"
|
||||
bind:value={settingsGamertag}
|
||||
placeholder="Short tag, e.g. CREW"
|
||||
maxLength={5}
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="settings-description"
|
||||
>Description <span class="optional">(optional)</span></label
|
||||
>
|
||||
<textarea
|
||||
id="settings-description"
|
||||
bind:value={settingsDescription}
|
||||
placeholder="Tell others about your crew"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#snippet children()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleCloseSettingsModal}
|
||||
disabled={updateCrewMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleUpdateSettings}
|
||||
variant="primary"
|
||||
disabled={!canSaveSettings || updateCrewMutation.isPending}
|
||||
>
|
||||
{updateCrewMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModalFooter>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<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;
|
||||
@use '$src/themes/colors' as colors;
|
||||
|
||||
.page {
|
||||
padding: spacing.$unit-2x 0;
|
||||
margin: 0 auto;
|
||||
max-width: var(--main-max-width);
|
||||
}
|
||||
|
||||
.card {
|
||||
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;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-4x;
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
// No crew state
|
||||
.no-crew-content {
|
||||
padding: spacing.$unit-3x spacing.$unit-2x;
|
||||
text-align: center;
|
||||
|
||||
.description {
|
||||
color: var(--text-primary);
|
||||
font-size: typography.$font-small;
|
||||
line-height: 1.5;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
max-width: 360px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.invitations-section {
|
||||
// No border - flows naturally from content above
|
||||
}
|
||||
|
||||
.invitation-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.invitation-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-2x;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.invitation-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.crew-name {
|
||||
font-size: typography.$font-regular;
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.invited-by {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-2x;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.stat-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: typography.$font-medium;
|
||||
font-weight: typography.$medium;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.role {
|
||||
font-size: typography.$font-small;
|
||||
|
||||
&.captain {
|
||||
color: var(--color-gold, #b8860b);
|
||||
}
|
||||
|
||||
&.officer {
|
||||
color: var(--color-blue, #3b82f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Section header
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// Event list (similar to member list)
|
||||
.event-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$item-corner;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
.event-number {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.event-dates {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-status {
|
||||
font-size: typography.$font-small;
|
||||
padding: 2px 6px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.status-active {
|
||||
background: var(--color-green-light, #dcfce7);
|
||||
color: var(--color-green-dark, #166534);
|
||||
}
|
||||
|
||||
&.status-upcoming {
|
||||
background: var(--color-blue-light, #dbeafe);
|
||||
color: var(--color-blue-dark, #1e40af);
|
||||
}
|
||||
|
||||
&.status-finished {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: layout.$item-corner-small;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
|
||||
&.element-null {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.element-fire {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.element-water {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.element-earth {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.element-wind {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.element-light {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&.element-dark {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: spacing.$unit-3x;
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
// Modal form styles
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(colors.$error, 0.1);
|
||||
border: 1px solid colors.$error;
|
||||
border-radius: layout.$card-corner;
|
||||
color: colors.$error;
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
|
||||
label {
|
||||
font-size: typography.$font-small;
|
||||
font-weight: typography.$medium;
|
||||
color: var(--text-primary);
|
||||
|
||||
.optional {
|
||||
font-weight: typography.$normal;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: spacing.$unit-2x;
|
||||
border: none;
|
||||
border-radius: layout.$input-corner;
|
||||
font-size: typography.$font-regular;
|
||||
font-family: inherit;
|
||||
background: var(--input-bound-bg);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bound-bg-hover);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(fieldset) {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
320
src/routes/(app)/crew/create/+page.svelte
Normal file
320
src/routes/(app)/crew/create/+page.svelte
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import { useCreateCrew } from '$lib/api/mutations/crew.mutations'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Check if user already has a crew
|
||||
const crewQuery = createQuery(() => crewQueries.myCrew())
|
||||
|
||||
// Redirect if user already has a crew
|
||||
$effect(() => {
|
||||
if (crewQuery.data && !crewQuery.isLoading) {
|
||||
goto('/crew')
|
||||
}
|
||||
})
|
||||
|
||||
// Form state
|
||||
let name = $state('')
|
||||
let gamertag = $state('')
|
||||
let granblueCrewId = $state('')
|
||||
let description = $state('')
|
||||
|
||||
// Validation state
|
||||
let errors = $state<Record<string, string>>({})
|
||||
|
||||
// Create mutation
|
||||
const createCrewMutation = useCreateCrew()
|
||||
|
||||
// Form validation
|
||||
function validate(): boolean {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Crew name is required'
|
||||
} else if (name.length > 100) {
|
||||
newErrors.name = 'Crew name must be 100 characters or less'
|
||||
}
|
||||
|
||||
if (gamertag && gamertag.length > 10) {
|
||||
newErrors.gamertag = 'Gamertag must be 10 characters or less'
|
||||
}
|
||||
|
||||
if (description && description.length > 500) {
|
||||
newErrors.description = 'Description must be 500 characters or less'
|
||||
}
|
||||
|
||||
errors = newErrors
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
// Form submission
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
try {
|
||||
const crew = await createCrewMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
gamertag: gamertag.trim() || undefined,
|
||||
granblueCrewId: granblueCrewId.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
})
|
||||
|
||||
// Update the store
|
||||
crewStore.setCrew(crew, crew.currentMembership ?? null)
|
||||
|
||||
// Navigate to crew dashboard
|
||||
goto('/crew')
|
||||
} catch (error: any) {
|
||||
// Handle API errors
|
||||
if (error.errors) {
|
||||
errors = error.errors
|
||||
} else {
|
||||
errors = { form: error.message || 'Failed to create crew' }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Create Crew | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="create-crew-page">
|
||||
{#if crewQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form-container">
|
||||
<header class="page-header">
|
||||
<h1>Create a Crew</h1>
|
||||
<p class="description">Start a new crew and invite players to join.</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="crew-form">
|
||||
{#if errors.form}
|
||||
<div class="form-error">
|
||||
{errors.form}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
Crew Name <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
class="form-input"
|
||||
class:error={errors.name}
|
||||
placeholder="Enter your crew name"
|
||||
maxlength="100"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<span class="field-error">{errors.name}</span>
|
||||
{/if}
|
||||
<span class="field-hint">{name.length}/100 characters</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gamertag" class="form-label">
|
||||
Gamertag
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="gamertag"
|
||||
bind:value={gamertag}
|
||||
class="form-input"
|
||||
class:error={errors.gamertag}
|
||||
placeholder="e.g., CREW"
|
||||
maxlength="10"
|
||||
/>
|
||||
{#if errors.gamertag}
|
||||
<span class="field-error">{errors.gamertag}</span>
|
||||
{/if}
|
||||
<span class="field-hint">
|
||||
Short tag displayed next to member usernames (optional)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="granblueCrewId" class="form-label">
|
||||
In-Game Crew ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="granblueCrewId"
|
||||
bind:value={granblueCrewId}
|
||||
class="form-input"
|
||||
class:error={errors.granblueCrewId}
|
||||
placeholder="Your Granblue Fantasy crew ID"
|
||||
/>
|
||||
{#if errors.granblueCrewId}
|
||||
<span class="field-error">{errors.granblueCrewId}</span>
|
||||
{/if}
|
||||
<span class="field-hint">
|
||||
The numeric ID from your in-game crew (optional)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
class="form-input form-textarea"
|
||||
class:error={errors.description}
|
||||
placeholder="Tell others about your crew"
|
||||
maxlength="500"
|
||||
rows="4"
|
||||
></textarea>
|
||||
{#if errors.description}
|
||||
<span class="field-error">{errors.description}</span>
|
||||
{/if}
|
||||
<span class="field-hint">{description.length}/500 characters</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onclick={() => goto('/crew')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={createCrewMutation.isPending}
|
||||
>
|
||||
{createCrewMutation.isPending ? 'Creating...' : 'Create Crew'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.create-crew-page {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: spacing.$unit-3x;
|
||||
|
||||
h1 {
|
||||
margin-bottom: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.crew-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: var(--error-background, #fef2f2);
|
||||
border: 1px solid var(--error-border, #fecaca);
|
||||
color: var(--error-text, #dc2626);
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.required {
|
||||
color: var(--error-text, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: spacing.$unit spacing.$unit-half;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--input-background, var(--background));
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-color, #3b82f6);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--error-border, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--error-text, #dc2626);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: spacing.$unit;
|
||||
margin-top: spacing.$unit;
|
||||
}
|
||||
</style>
|
||||
308
src/routes/(app)/crew/join/+page.svelte
Normal file
308
src/routes/(app)/crew/join/+page.svelte
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import { useAcceptInvitation, useRejectInvitation } from '$lib/api/mutations/crew.mutations'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Check if user already has a crew - redirect to crew page
|
||||
$effect(() => {
|
||||
if (crewStore.isInCrew && !crewStore.isLoading) {
|
||||
goto('/crew')
|
||||
}
|
||||
})
|
||||
|
||||
// Get invitation ID from URL if present
|
||||
const selectedInvitationId = $derived($page.url.searchParams.get('invitation'))
|
||||
|
||||
// Query for pending invitations
|
||||
const invitationsQuery = createQuery(() => crewQueries.pendingInvitations())
|
||||
|
||||
// Mutations
|
||||
const acceptMutation = useAcceptInvitation()
|
||||
const rejectMutation = useRejectInvitation()
|
||||
|
||||
// Track which invitation is being processed
|
||||
let processingId = $state<string | null>(null)
|
||||
|
||||
// Accept invitation
|
||||
async function handleAccept(invitationId: string) {
|
||||
processingId = invitationId
|
||||
try {
|
||||
await acceptMutation.mutateAsync(invitationId)
|
||||
// Successfully joined - redirect to crew
|
||||
goto('/crew')
|
||||
} catch (error) {
|
||||
console.error('Failed to accept invitation:', error)
|
||||
processingId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Reject invitation
|
||||
async function handleReject(invitationId: string) {
|
||||
processingId = invitationId
|
||||
try {
|
||||
await rejectMutation.mutateAsync(invitationId)
|
||||
processingId = null
|
||||
} catch (error) {
|
||||
console.error('Failed to reject invitation:', error)
|
||||
processingId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Check if invitation is expired
|
||||
function isExpired(expiresAt: string): boolean {
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Join Crew | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="join-page">
|
||||
<header class="page-header">
|
||||
<h1>Join a Crew</h1>
|
||||
<p class="description">Accept an invitation to join a crew.</p>
|
||||
</header>
|
||||
|
||||
{#if invitationsQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading invitations...</p>
|
||||
</div>
|
||||
{:else if invitationsQuery.isError}
|
||||
<div class="error-state">
|
||||
<p>Failed to load invitations</p>
|
||||
</div>
|
||||
{:else if !invitationsQuery.data || invitationsQuery.data.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>You don't have any pending invitations.</p>
|
||||
<p class="hint">
|
||||
Ask a crew captain or vice captain to send you an invitation.
|
||||
</p>
|
||||
<Button variant="secondary" onclick={() => goto('/crew')}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="invitations-list">
|
||||
{#each invitationsQuery.data as invitation}
|
||||
{@const expired = isExpired(invitation.expiresAt)}
|
||||
{@const highlighted = invitation.id === selectedInvitationId}
|
||||
{@const crew = invitation.crew}
|
||||
{@const invitedBy = invitation.invitedBy}
|
||||
|
||||
{#if crew && invitedBy}
|
||||
<div class="invitation-card" class:highlighted class:expired>
|
||||
<div class="invitation-header">
|
||||
<h2 class="crew-name">{crew.name}</h2>
|
||||
{#if crew.gamertag}
|
||||
<span class="gamertag">[{crew.gamertag}]</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if 'description' in crew && crew.description}
|
||||
<p class="crew-description">{crew.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="invitation-meta">
|
||||
<span class="invited-by">
|
||||
Invited by <strong>{invitedBy.username}</strong>
|
||||
</span>
|
||||
<span class="invited-date">
|
||||
{formatDate(invitation.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if 'memberCount' in crew && crew.memberCount !== undefined}
|
||||
<div class="crew-stats">
|
||||
<span class="stat">
|
||||
{crew.memberCount} member{crew.memberCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if expired}
|
||||
<div class="expired-notice">
|
||||
This invitation has expired.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="expires-notice">
|
||||
Expires: {formatDate(invitation.expiresAt)}
|
||||
</div>
|
||||
|
||||
<div class="invitation-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onclick={() => handleReject(invitation.id)}
|
||||
disabled={processingId === invitation.id}
|
||||
>
|
||||
{processingId === invitation.id && rejectMutation.isPending ? 'Declining...' : 'Decline'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onclick={() => handleAccept(invitation.id)}
|
||||
disabled={processingId === invitation.id}
|
||||
>
|
||||
{processingId === invitation.id && acceptMutation.isPending ? 'Joining...' : 'Accept'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.join-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: spacing.$unit-3x;
|
||||
|
||||
h1 {
|
||||
margin-bottom: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-4x spacing.$unit-2x;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.invitations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.invitation-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: spacing.$unit-2x;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&.highlighted {
|
||||
border-color: var(--color-blue, #3b82f6);
|
||||
box-shadow: 0 0 0 3px var(--color-blue-light, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
&.expired {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.invitation-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: spacing.$unit;
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
.crew-name {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gamertag {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.crew-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invitation-meta {
|
||||
display: flex;
|
||||
gap: spacing.$unit-2x;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
.crew-stats {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.expired-notice {
|
||||
color: var(--color-red, #dc2626);
|
||||
font-size: 0.875rem;
|
||||
padding: spacing.$unit;
|
||||
background: var(--color-red-light, #fef2f2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expires-notice {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.invitation-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
</style>
|
||||
555
src/routes/(app)/crew/settings/+page.svelte
Normal file
555
src/routes/(app)/crew/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import {
|
||||
useUpdateCrew,
|
||||
useLeaveCrew,
|
||||
useTransferCaptain
|
||||
} from '$lib/api/mutations/crew.mutations'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { Dialog } from 'bits-ui'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Check if user is an officer
|
||||
$effect(() => {
|
||||
if (!crewStore.isLoading && !crewStore.isOfficer) {
|
||||
goto('/crew')
|
||||
}
|
||||
})
|
||||
|
||||
// Form state (pre-populated from store)
|
||||
let name = $state(crewStore.crew?.name ?? '')
|
||||
let gamertag = $state(crewStore.crew?.gamertag ?? '')
|
||||
let granblueCrewId = $state(crewStore.crew?.granblueCrewId ?? '')
|
||||
let description = $state(crewStore.crew?.description ?? '')
|
||||
|
||||
// Sync form state when crew data changes
|
||||
$effect(() => {
|
||||
if (crewStore.crew) {
|
||||
name = crewStore.crew.name
|
||||
gamertag = crewStore.crew.gamertag ?? ''
|
||||
granblueCrewId = crewStore.crew.granblueCrewId ?? ''
|
||||
description = crewStore.crew.description ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
// Validation state
|
||||
let errors = $state<Record<string, string>>({})
|
||||
|
||||
// Mutations
|
||||
const updateCrewMutation = useUpdateCrew()
|
||||
const leaveCrewMutation = useLeaveCrew()
|
||||
const transferCaptainMutation = useTransferCaptain()
|
||||
|
||||
// Query for members (for captain transfer)
|
||||
const membersQuery = createQuery(() => crewQueries.members('active'))
|
||||
|
||||
// Dialog states
|
||||
let leaveDialogOpen = $state(false)
|
||||
let transferDialogOpen = $state(false)
|
||||
let selectedTransferUserId = $state<string | null>(null)
|
||||
|
||||
// Form validation
|
||||
function validate(): boolean {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Crew name is required'
|
||||
} else if (name.length > 100) {
|
||||
newErrors.name = 'Crew name must be 100 characters or less'
|
||||
}
|
||||
|
||||
if (gamertag && gamertag.length > 10) {
|
||||
newErrors.gamertag = 'Gamertag must be 10 characters or less'
|
||||
}
|
||||
|
||||
if (description && description.length > 500) {
|
||||
newErrors.description = 'Description must be 500 characters or less'
|
||||
}
|
||||
|
||||
errors = newErrors
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
// Form submission
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
try {
|
||||
const crew = await updateCrewMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
gamertag: gamertag.trim() || undefined,
|
||||
granblueCrewId: granblueCrewId.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
})
|
||||
|
||||
// Update the store
|
||||
crewStore.setCrew(crew, crewStore.membership)
|
||||
} catch (error: any) {
|
||||
if (error.errors) {
|
||||
errors = error.errors
|
||||
} else {
|
||||
errors = { form: error.message || 'Failed to update crew' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave crew
|
||||
async function handleLeaveCrew() {
|
||||
try {
|
||||
await leaveCrewMutation.mutateAsync()
|
||||
crewStore.clear()
|
||||
goto('/crew')
|
||||
} catch (error) {
|
||||
console.error('Failed to leave crew:', error)
|
||||
}
|
||||
leaveDialogOpen = false
|
||||
}
|
||||
|
||||
// Transfer captain
|
||||
async function handleTransferCaptain() {
|
||||
if (!selectedTransferUserId || !crewStore.crew) return
|
||||
|
||||
try {
|
||||
await transferCaptainMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
userId: selectedTransferUserId
|
||||
})
|
||||
// Membership will be updated via query invalidation
|
||||
goto('/crew')
|
||||
} catch (error) {
|
||||
console.error('Failed to transfer captain:', error)
|
||||
}
|
||||
transferDialogOpen = false
|
||||
}
|
||||
|
||||
// Get eligible transfer candidates (non-captain members)
|
||||
const transferCandidates = $derived(
|
||||
membersQuery.data?.members.filter((m) => m.role !== 'captain' && !m.retired) ?? []
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crew Settings | Hensei</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-page">
|
||||
<header class="page-header">
|
||||
<h1>Crew Settings</h1>
|
||||
</header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="settings-form">
|
||||
{#if errors.form}
|
||||
<div class="form-error">
|
||||
{errors.form}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
Crew Name <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
class="form-input"
|
||||
class:error={errors.name}
|
||||
maxlength="100"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<span class="field-error">{errors.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gamertag" class="form-label"> Gamertag </label>
|
||||
<input
|
||||
type="text"
|
||||
id="gamertag"
|
||||
bind:value={gamertag}
|
||||
class="form-input"
|
||||
class:error={errors.gamertag}
|
||||
maxlength="10"
|
||||
/>
|
||||
{#if errors.gamertag}
|
||||
<span class="field-error">{errors.gamertag}</span>
|
||||
{/if}
|
||||
<span class="field-hint"> Short tag displayed next to member usernames </span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="granblueCrewId" class="form-label"> In-Game Crew ID </label>
|
||||
<input type="text" id="granblueCrewId" bind:value={granblueCrewId} class="form-input" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label"> Description </label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
class="form-input form-textarea"
|
||||
class:error={errors.description}
|
||||
maxlength="500"
|
||||
rows="4"
|
||||
></textarea>
|
||||
{#if errors.description}
|
||||
<span class="field-error">{errors.description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button variant="primary" type="submit" disabled={updateCrewMutation.isPending}>
|
||||
{updateCrewMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
|
||||
{#if crewStore.isCaptain}
|
||||
<div class="danger-item">
|
||||
<div class="danger-info">
|
||||
<h3>Transfer Captain</h3>
|
||||
<p>Transfer ownership of the crew to another member.</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="small" onclick={() => (transferDialogOpen = true)}>
|
||||
Transfer
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if crewStore.canLeaveCrew}
|
||||
<div class="danger-item">
|
||||
<div class="danger-info">
|
||||
<h3>Leave Crew</h3>
|
||||
<p>Leave this crew. You'll need an invitation to rejoin.</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="small" onclick={() => (leaveDialogOpen = true)}>
|
||||
Leave Crew
|
||||
</Button>
|
||||
</div>
|
||||
{:else if crewStore.isCaptain}
|
||||
<p class="captain-note">As captain, you must transfer ownership before leaving the crew.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leave Crew Dialog -->
|
||||
<Dialog.Root bind:open={leaveDialogOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<Dialog.Title class="dialog-title">Leave Crew</Dialog.Title>
|
||||
<Dialog.Description class="dialog-description">
|
||||
Are you sure you want to leave {crewStore.crew?.name}? You'll need an invitation to rejoin.
|
||||
</Dialog.Description>
|
||||
<div class="dialog-actions">
|
||||
<Dialog.Close class="dialog-button secondary">Cancel</Dialog.Close>
|
||||
<button class="dialog-button primary danger" onclick={handleLeaveCrew}> Leave Crew </button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Transfer Captain Dialog -->
|
||||
<Dialog.Root bind:open={transferDialogOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<Dialog.Title class="dialog-title">Transfer Captain</Dialog.Title>
|
||||
<Dialog.Description class="dialog-description">
|
||||
Select a member to become the new captain. You will become a regular member.
|
||||
</Dialog.Description>
|
||||
|
||||
{#if transferCandidates.length === 0}
|
||||
<p class="no-candidates">
|
||||
No eligible members to transfer to. The crew needs at least one other member.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="transfer-list">
|
||||
{#each transferCandidates as member}
|
||||
<label class="transfer-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="transferTarget"
|
||||
value={member.user?.id}
|
||||
bind:group={selectedTransferUserId}
|
||||
/>
|
||||
<span class="member-name">{member.user?.username}</span>
|
||||
<span class="member-role">
|
||||
{member.role === 'vice_captain' ? 'Vice Captain' : 'Member'}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<Dialog.Close class="dialog-button secondary">Cancel</Dialog.Close>
|
||||
<button
|
||||
class="dialog-button primary"
|
||||
onclick={handleTransferCaptain}
|
||||
disabled={!selectedTransferUserId || transferCandidates.length === 0}
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.settings-page {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: spacing.$unit-3x;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
margin-bottom: spacing.$unit-4x;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: var(--error-background, #fef2f2);
|
||||
border: 1px solid var(--error-border, #fecaca);
|
||||
color: var(--error-text, #dc2626);
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.required {
|
||||
color: var(--error-text, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: spacing.$unit spacing.$unit-half;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--input-background, var(--background));
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-color, #3b82f6);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--error-border, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--error-text, #dc2626);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: spacing.$unit;
|
||||
}
|
||||
|
||||
// Danger zone
|
||||
.danger-zone {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: spacing.$unit-3x;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
color: var(--color-red, #dc2626);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: spacing.$unit-2x;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
.danger-info {
|
||||
h3 {
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 spacing.$unit-half 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.captain-note {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Dialog styles
|
||||
:global(.dialog-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
|
||||
.dialog-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: spacing.$unit;
|
||||
margin-top: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--color-blue, #3b82f6);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-blue-dark, #2563eb);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: var(--color-red, #dc2626);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-red-dark, #b91c1c);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-candidates {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.transfer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transfer-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
src/routes/(app)/database/gw-events/+page.svelte
Normal file
329
src/routes/(app)/database/gw-events/+page.svelte
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import type { GwEvent } from '$lib/types/api/gw'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// State
|
||||
let searchTerm = $state('')
|
||||
|
||||
// Query for GW events
|
||||
const eventsQuery = createQuery(() => ({
|
||||
queryKey: ['gw', 'events', 'admin'],
|
||||
queryFn: () => gwAdapter.getEvents(),
|
||||
staleTime: 1000 * 60 * 5
|
||||
}))
|
||||
|
||||
// Element labels (matches GranblueEnums::ELEMENTS)
|
||||
const elementLabels: Record<number, string> = {
|
||||
0: 'Null',
|
||||
1: 'Wind',
|
||||
2: 'Fire',
|
||||
3: 'Water',
|
||||
4: 'Earth',
|
||||
5: 'Dark',
|
||||
6: 'Light'
|
||||
}
|
||||
|
||||
// Element colors for badges
|
||||
const elementColors: Record<number, string> = {
|
||||
0: 'null',
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
|
||||
// Filter events by search
|
||||
const filteredEvents = $derived.by(() => {
|
||||
const events = eventsQuery.data ?? []
|
||||
if (!searchTerm.trim()) return events
|
||||
|
||||
const term = searchTerm.toLowerCase()
|
||||
return events.filter(
|
||||
(e) =>
|
||||
String(e.eventNumber).includes(term) ||
|
||||
elementLabels[e.element]?.toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Navigate to event detail/edit
|
||||
function handleRowClick(event: GwEvent) {
|
||||
goto(`/database/gw-events/${event.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="grid">
|
||||
<div class="controls">
|
||||
<input type="text" placeholder="Search events..." bind:value={searchTerm} />
|
||||
<div class="controls-right">
|
||||
<Button variant="primary" size="small" onclick={() => goto('/database/gw-events/new')}>
|
||||
New Event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-wrapper" class:loading={eventsQuery.isLoading}>
|
||||
{#if eventsQuery.isLoading}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-number">#</th>
|
||||
<th class="col-element">Element</th>
|
||||
<th class="col-dates">Dates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if filteredEvents.length === 0 && !eventsQuery.isLoading}
|
||||
<tr>
|
||||
<td colspan="3" class="empty-state">
|
||||
{searchTerm ? 'No events match your search' : 'No GW events yet'}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filteredEvents as event}
|
||||
<tr onclick={() => handleRowClick(event)} class="clickable">
|
||||
<td class="col-number">
|
||||
<span class="event-number">{event.eventNumber}</span>
|
||||
</td>
|
||||
<td class="col-element">
|
||||
<span class="element-badge element-{elementColors[event.element]}">
|
||||
{elementLabels[event.element] ?? 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-dates">
|
||||
<span class="dates">
|
||||
{formatDate(event.startDate)} - {formatDate(event.endDate)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid-footer">
|
||||
<div class="pagination-info">
|
||||
{filteredEvents.length} event{filteredEvents.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: spacing.$unit-2x 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;
|
||||
}
|
||||
}
|
||||
|
||||
.events-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-number {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.col-element {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.col-dates {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.event-number {
|
||||
font-weight: typography.$bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: 500;
|
||||
|
||||
&.element-fire {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.element-water {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.element-earth {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.element-wind {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.element-light {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&.element-dark {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.dates {
|
||||
font-size: typography.$font-small;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.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>
|
||||
206
src/routes/(app)/database/gw-events/[id]/+page.svelte
Normal file
206
src/routes/(app)/database/gw-events/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { createQuery } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.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()
|
||||
|
||||
// Get event ID from URL
|
||||
const eventId = $derived($page.params.id)
|
||||
|
||||
// Query for event data
|
||||
const eventQuery = createQuery(() => ({
|
||||
queryKey: ['gw', 'events', eventId],
|
||||
queryFn: () => gwAdapter.getEvent(eventId),
|
||||
enabled: !!eventId
|
||||
}))
|
||||
|
||||
const event = $derived(eventQuery.data)
|
||||
const userRole = $derived(data.role || 0)
|
||||
const canEdit = $derived(userRole >= 7)
|
||||
|
||||
// Element labels and colors (matches GranblueEnums::ELEMENTS)
|
||||
const elementLabels: Record<number, string> = {
|
||||
0: 'Null',
|
||||
1: 'Wind',
|
||||
2: 'Fire',
|
||||
3: 'Water',
|
||||
4: 'Earth',
|
||||
5: 'Dark',
|
||||
6: 'Light'
|
||||
}
|
||||
|
||||
const elementColors: Record<number, string> = {
|
||||
0: 'null',
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Navigate to edit
|
||||
function handleEdit() {
|
||||
goto(`/database/gw-events/${eventId}/edit`)
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
function handleBack() {
|
||||
goto('/database/gw-events')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if eventQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading event...</p>
|
||||
</div>
|
||||
{:else if eventQuery.isError}
|
||||
<div class="error-state">
|
||||
<p>Failed to load event</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Events</Button>
|
||||
</div>
|
||||
{:else if event}
|
||||
<SidebarHeader title={`GW #${event.eventNumber}`}>
|
||||
{#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="Event Details">
|
||||
<DetailItem label="Event Number" value={`#${event.eventNumber}`} />
|
||||
<DetailItem label="Element">
|
||||
<span class="element-badge element-{elementColors[event.element]}">
|
||||
{elementLabels[event.element] ?? 'Unknown'}
|
||||
</span>
|
||||
</DetailItem>
|
||||
<DetailItem label="Start Date" value={formatDate(event.startDate)} />
|
||||
<DetailItem label="End Date" value={formatDate(event.endDate)} />
|
||||
</DetailsContainer>
|
||||
|
||||
{#if event.createdAt}
|
||||
<DetailsContainer title="Metadata">
|
||||
<DetailItem label="Created" value={formatDate(event.createdAt)} />
|
||||
{#if event.updatedAt}
|
||||
<DetailItem label="Updated" value={formatDate(event.updatedAt)} />
|
||||
{/if}
|
||||
</DetailsContainer>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Event Not Found</h2>
|
||||
<p>The event you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={handleBack}>Back to Events</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: layout.$card-corner;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.element-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: typography.$font-small;
|
||||
font-weight: 500;
|
||||
|
||||
&.element-fire {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.element-water {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.element-earth {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.element-wind {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.element-light {
|
||||
background: #fef9c3;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&.element-dark {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
223
src/routes/(app)/database/gw-events/[id]/edit/+page.svelte
Normal file
223
src/routes/(app)/database/gw-events/[id]/edit/+page.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<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 { gwAdapter } from '$lib/api/adapters/gw.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 event ID from URL
|
||||
const eventId = $derived($page.params.id)
|
||||
|
||||
// Query for event data
|
||||
const eventQuery = createQuery(() => ({
|
||||
queryKey: ['gw', 'events', eventId],
|
||||
queryFn: () => gwAdapter.getEvent(eventId),
|
||||
enabled: !!eventId
|
||||
}))
|
||||
|
||||
const event = $derived(eventQuery.data)
|
||||
|
||||
// Save state
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
|
||||
// Edit data state
|
||||
let editData = $state({
|
||||
eventNumber: 0,
|
||||
element: 0,
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// Sync edit data when event changes
|
||||
$effect(() => {
|
||||
if (event) {
|
||||
editData = {
|
||||
eventNumber: event.eventNumber,
|
||||
element: event.element,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Element options (matches GranblueEnums::ELEMENTS, excluding Null)
|
||||
const elementOptions = [
|
||||
{ value: 1, label: 'Wind' },
|
||||
{ value: 2, label: 'Fire' },
|
||||
{ value: 3, label: 'Water' },
|
||||
{ value: 4, label: 'Earth' },
|
||||
{ value: 5, label: 'Dark' },
|
||||
{ value: 6, label: 'Light' }
|
||||
]
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.eventNumber > 0 &&
|
||||
editData.startDate !== '' &&
|
||||
editData.endDate !== ''
|
||||
)
|
||||
|
||||
// Save changes
|
||||
async function handleSave() {
|
||||
if (!canSave || !event) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
await gwAdapter.updateEvent(event.id, {
|
||||
eventNumber: editData.eventNumber,
|
||||
element: editData.element,
|
||||
startDate: editData.startDate,
|
||||
endDate: editData.endDate
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['gw', 'events'] })
|
||||
|
||||
// Navigate back to detail page
|
||||
goto(`/database/gw-events/${eventId}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to save event'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto(`/database/gw-events/${eventId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if eventQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<p>Loading event...</p>
|
||||
</div>
|
||||
{:else if event}
|
||||
<SidebarHeader title="Edit Event">
|
||||
{#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="Event Details">
|
||||
<DetailItem
|
||||
label="Event Number"
|
||||
bind:value={editData.eventNumber}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Element"
|
||||
bind:value={editData.element}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={elementOptions}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Start Date"
|
||||
bind:value={editData.startDate}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<DetailItem
|
||||
label="End Date"
|
||||
bind:value={editData.endDate}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<h2>Event Not Found</h2>
|
||||
<p>The event you're looking for could not be found.</p>
|
||||
<Button variant="secondary" onclick={() => goto('/database/gw-events')}>
|
||||
Back to Events
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: layout.$card-corner;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.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>
|
||||
153
src/routes/(app)/database/gw-events/new/+page.svelte
Normal file
153
src/routes/(app)/database/gw-events/new/+page.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { useQueryClient } from '@tanstack/svelte-query'
|
||||
import { gwAdapter } from '$lib/api/adapters/gw.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({
|
||||
eventNumber: 0,
|
||||
element: 2, // Fire
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// Element options (matches GranblueEnums::ELEMENTS, excluding Null)
|
||||
const elementOptions = [
|
||||
{ value: 1, label: 'Wind' },
|
||||
{ value: 2, label: 'Fire' },
|
||||
{ value: 3, label: 'Water' },
|
||||
{ value: 4, label: 'Earth' },
|
||||
{ value: 5, label: 'Dark' },
|
||||
{ value: 6, label: 'Light' }
|
||||
]
|
||||
|
||||
// Validation
|
||||
const canSave = $derived(
|
||||
editData.eventNumber > 0 && editData.startDate !== '' && editData.endDate !== ''
|
||||
)
|
||||
|
||||
// Create event
|
||||
async function handleSave() {
|
||||
if (!canSave) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
|
||||
try {
|
||||
const newEvent = await gwAdapter.createEvent({
|
||||
eventNumber: editData.eventNumber,
|
||||
element: editData.element,
|
||||
startDate: editData.startDate,
|
||||
endDate: editData.endDate
|
||||
})
|
||||
|
||||
// Invalidate queries
|
||||
await queryClient.invalidateQueries({ queryKey: ['gw', 'events'] })
|
||||
|
||||
// Navigate to the new event's detail page
|
||||
goto(`/database/gw-events/${newEvent.id}`)
|
||||
} catch (error: any) {
|
||||
saveError = error.message || 'Failed to create event'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
function handleCancel() {
|
||||
goto('/database/gw-events')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SidebarHeader title="New Event">
|
||||
{#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="Event Details">
|
||||
<DetailItem
|
||||
label="Event number"
|
||||
bind:value={editData.eventNumber}
|
||||
editable={true}
|
||||
type="number"
|
||||
/>
|
||||
<DetailItem
|
||||
label="Element advantage"
|
||||
bind:value={editData.element}
|
||||
editable={true}
|
||||
type="select"
|
||||
options={elementOptions}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Start date"
|
||||
bind:value={editData.startDate}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<DetailItem
|
||||
label="End date"
|
||||
bind:value={editData.endDate}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.page {
|
||||
background: white;
|
||||
border-radius: layout.$card-corner;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.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