add crew pages: dashboard, create, join, settings, gw events admin

This commit is contained in:
Justin Edmund 2025-12-04 03:03:33 -08:00
parent aee0690b2d
commit eaea344db4
10 changed files with 2919 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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