hensei-web/src/routes/(app)/crew/+page.svelte

770 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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