770 lines
17 KiB
Svelte
770 lines
17 KiB
Svelte
<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>
|