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