add crew roster page for checking member collections
This commit is contained in:
parent
defacc2179
commit
fe3ff17367
1 changed files with 550 additions and 0 deletions
550
src/routes/(app)/crew/roster/+page.svelte
Normal file
550
src/routes/(app)/crew/roster/+page.svelte
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import Svelecte from 'svelecte'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
||||
import {
|
||||
searchAdapter,
|
||||
type UnifiedSearchResult,
|
||||
type UnifiedSearchSeriesRef
|
||||
} from '$lib/api/adapters/search.adapter'
|
||||
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/utils/images'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import RichTooltip from '$lib/components/ui/RichTooltip.svelte'
|
||||
import CharacterTags from '$lib/components/tags/CharacterTags.svelte'
|
||||
import SearchOptionItem from '$lib/components/search/SearchOptionItem.svelte'
|
||||
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import CrewTabs from '$lib/components/crew/CrewTabs.svelte'
|
||||
import type { RosterMember, RosterItem } from '$lib/types/api/crew'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Item type for roster
|
||||
type ItemType = 'Character' | 'Weapon' | 'Summon'
|
||||
|
||||
// Selected item with metadata
|
||||
interface SelectedItem {
|
||||
id: string
|
||||
granblueId: string
|
||||
name: string
|
||||
type: ItemType
|
||||
// Character-specific fields for CharacterTags
|
||||
element?: number
|
||||
season?: number | null
|
||||
series?: UnifiedSearchSeriesRef[] | null
|
||||
}
|
||||
|
||||
// Option format for Svelecte
|
||||
interface SearchOption {
|
||||
value: string // composite key: id|type
|
||||
label: string
|
||||
id: string
|
||||
granblueId: string
|
||||
type: ItemType
|
||||
// Character-specific fields
|
||||
element?: number
|
||||
season?: number | null
|
||||
series?: UnifiedSearchSeriesRef[] | null
|
||||
}
|
||||
|
||||
// State
|
||||
let searchOptions = $state<SearchOption[]>([])
|
||||
let isSearching = $state(false)
|
||||
let selectedItems = $state<SelectedItem[]>([])
|
||||
let rosterData = $state<RosterMember[]>([])
|
||||
let isLoadingRoster = $state(false)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Check if user is an officer
|
||||
$effect(() => {
|
||||
if (!crewStore.isLoading && !crewStore.isOfficer) {
|
||||
goto('/crew')
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch roster on mount and when items change
|
||||
$effect(() => {
|
||||
// Track selectedItems to trigger refetch
|
||||
const _ = selectedItems.length
|
||||
fetchRoster()
|
||||
})
|
||||
|
||||
async function searchItems(query: string) {
|
||||
if (query.length < 2) {
|
||||
searchOptions = []
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
try {
|
||||
const response = await searchAdapter.searchAll({ query, per: 20 })
|
||||
searchOptions = (response?.results ?? []).map((result: UnifiedSearchResult) => ({
|
||||
value: `${result.searchableId}|${result.searchableType}`,
|
||||
label: result.nameEn || 'Unknown',
|
||||
id: result.searchableId,
|
||||
granblueId: result.granblueId,
|
||||
type: result.searchableType,
|
||||
element: result.element,
|
||||
season: result.season,
|
||||
series: result.series
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
searchOptions = []
|
||||
} finally {
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const query = target?.value ?? ''
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => searchItems(query), 300)
|
||||
}
|
||||
|
||||
function handleChange(selected: SearchOption | null) {
|
||||
if (!selected) return
|
||||
|
||||
// Don't add duplicates
|
||||
if (selectedItems.some((item) => item.id === selected.id && item.type === selected.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedItems = [
|
||||
...selectedItems,
|
||||
{
|
||||
id: selected.id,
|
||||
granblueId: selected.granblueId,
|
||||
name: selected.label,
|
||||
type: selected.type,
|
||||
element: selected.element,
|
||||
season: selected.season,
|
||||
series: selected.series
|
||||
}
|
||||
]
|
||||
|
||||
// Clear selection after adding
|
||||
searchOptions = []
|
||||
}
|
||||
|
||||
function removeItem(id: string, type: ItemType) {
|
||||
selectedItems = selectedItems.filter((item) => !(item.id === id && item.type === type))
|
||||
}
|
||||
|
||||
async function fetchRoster() {
|
||||
isLoadingRoster = true
|
||||
try {
|
||||
const query = {
|
||||
characterIds: selectedItems.filter((i) => i.type === 'Character').map((i) => i.id),
|
||||
weaponIds: selectedItems.filter((i) => i.type === 'Weapon').map((i) => i.id),
|
||||
summonIds: selectedItems.filter((i) => i.type === 'Summon').map((i) => i.id)
|
||||
}
|
||||
|
||||
const response = await crewAdapter.getRoster(query)
|
||||
rosterData = response.members
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch roster:', error)
|
||||
rosterData = []
|
||||
} finally {
|
||||
isLoadingRoster = false
|
||||
}
|
||||
}
|
||||
|
||||
function getItemImage(item: SelectedItem): string {
|
||||
switch (item.type) {
|
||||
case 'Character':
|
||||
return getCharacterImage(item.granblueId, 'square', '01')
|
||||
case 'Weapon':
|
||||
return getWeaponImage(item.granblueId, 'square')
|
||||
case 'Summon':
|
||||
return getSummonImage(item.granblueId, 'square')
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnershipInfo(member: RosterMember, item: SelectedItem): RosterItem | null {
|
||||
const collection =
|
||||
item.type === 'Character'
|
||||
? member.characters
|
||||
: item.type === 'Weapon'
|
||||
? member.weapons
|
||||
: member.summons
|
||||
|
||||
return collection.find((c) => c.id === item.id) || null
|
||||
}
|
||||
|
||||
function getItemTypeForUncap(type: ItemType): 'character' | 'weapon' | 'summon' {
|
||||
return type.toLowerCase() as 'character' | 'weapon' | 'summon'
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
switch (role) {
|
||||
case 'captain':
|
||||
return 'Captain'
|
||||
case 'vice_captain':
|
||||
return 'Vice Captain'
|
||||
default:
|
||||
return 'Member'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crew Roster / granblue.team</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<CrewHeader
|
||||
title={crewStore.crew?.name ?? ''}
|
||||
subtitle={crewStore.crew?.gamertag ?? undefined}
|
||||
description={crewStore.crew?.description ?? undefined}
|
||||
/>
|
||||
|
||||
<CrewTabs userElement={data.currentUser?.element} />
|
||||
|
||||
<div class="roster-content">
|
||||
<!-- Search Input -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-section" oninput={handleInput}>
|
||||
<Svelecte
|
||||
options={searchOptions}
|
||||
value={null}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
searchable={true}
|
||||
placeholder="Search characters, weapons, summons..."
|
||||
clearable={false}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{#snippet option(opt)}
|
||||
{@const item = opt as SearchOption}
|
||||
<SearchOptionItem
|
||||
label={item.label}
|
||||
granblueId={item.granblueId}
|
||||
type={item.type}
|
||||
element={item.element}
|
||||
season={item.season}
|
||||
series={item.series}
|
||||
/>
|
||||
{/snippet}
|
||||
</Svelecte>
|
||||
{#if isSearching}
|
||||
<span class="loading-indicator">...</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Roster Grid -->
|
||||
<div class="roster-section">
|
||||
{#if isLoadingRoster}
|
||||
<div class="loading-roster">Loading...</div>
|
||||
{:else if rosterData.length > 0}
|
||||
<div class="roster-grid">
|
||||
{#if selectedItems.length > 0}
|
||||
<div class="roster-header">
|
||||
<div class="member-col"></div>
|
||||
{#each selectedItems as item (item.id + item.type)}
|
||||
<div class="item-col">
|
||||
<RichTooltip>
|
||||
{#snippet content()}
|
||||
<div class="tooltip-content">
|
||||
<span class="item-name">{item.name}</span>
|
||||
{#if item.type === 'Character'}
|
||||
<CharacterTags
|
||||
character={{
|
||||
element: item.element,
|
||||
season: item.season,
|
||||
series: item.series
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<div class="header-item-wrapper">
|
||||
<img
|
||||
src={getItemImage(item)}
|
||||
alt={item.name}
|
||||
class="header-item-image"
|
||||
/>
|
||||
<button
|
||||
class="remove-item-btn"
|
||||
onclick={() => removeItem(item.id, item.type)}
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</RichTooltip>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="roster-body">
|
||||
{#each rosterData as member (member.userId)}
|
||||
<div class="roster-row">
|
||||
<div class="member-col">
|
||||
<span class="member-name">{member.username}</span>
|
||||
{#if member.role !== 'member'}
|
||||
<span class="member-role">{getRoleLabel(member.role)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each selectedItems as item (item.id + item.type)}
|
||||
{@const ownership = getOwnershipInfo(member, item)}
|
||||
<div class="item-col ownership-cell">
|
||||
{#if ownership}
|
||||
<UncapIndicator
|
||||
type={getItemTypeForUncap(item.type)}
|
||||
uncapLevel={ownership.uncapLevel}
|
||||
/>
|
||||
{:else}
|
||||
<span class="not-owned">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-roster">No crew members found</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@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 {
|
||||
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;
|
||||
// Allow dropdown to overflow - don't clip Svelecte dropdown
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.roster-content {
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
position: relative;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
|
||||
// Svelecte CSS variable overrides
|
||||
--sv-bg: var(--select-contained-bg);
|
||||
--sv-border-color: transparent;
|
||||
--sv-border: 1px solid var(--sv-border-color);
|
||||
--sv-active-border: 1px solid colors.$blue;
|
||||
--sv-active-outline: none;
|
||||
--sv-border-radius: #{layout.$input-corner};
|
||||
--sv-min-height: #{spacing.$unit-4x};
|
||||
--sv-placeholder-color: var(--text-tertiary);
|
||||
--sv-color: var(--text-primary);
|
||||
|
||||
--sv-dropdown-bg: var(--dialog-bg);
|
||||
--sv-dropdown-border-radius: #{layout.$card-corner};
|
||||
--sv-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--sv-dropdown-offset: #{spacing.$unit-half};
|
||||
|
||||
--sv-item-color: var(--text-primary);
|
||||
--sv-item-active-bg: var(--option-bg-hover);
|
||||
--sv-item-selected-bg: var(--option-bg-hover);
|
||||
|
||||
--sv-icon-color: var(--text-tertiary);
|
||||
--sv-icon-hover-color: var(--text-primary);
|
||||
|
||||
// Target Svelecte control for hover states
|
||||
:global(.sv-control) {
|
||||
padding: calc(spacing.$unit-half + 1px) spacing.$unit calc(spacing.$unit-half + 1px) spacing.$unit-half;
|
||||
}
|
||||
|
||||
&:hover :global(.sv-control) {
|
||||
background-color: var(--select-contained-bg-hover);
|
||||
}
|
||||
|
||||
// Style the dropdown
|
||||
:global(.sv_dropdown) {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: layout.$card-corner !important;
|
||||
max-height: 40vh;
|
||||
z-index: 102;
|
||||
}
|
||||
|
||||
// Style dropdown item wrappers
|
||||
:global(.sv-item--wrap.in-dropdown) {
|
||||
padding: spacing.$unit-half spacing.$unit spacing.$unit-half spacing.$unit-half;
|
||||
border-radius: layout.$item-corner;
|
||||
}
|
||||
|
||||
// Style active/highlighted dropdown item
|
||||
:global(.in-dropdown.sv-dd-item-active),
|
||||
:global(.in-dropdown:hover),
|
||||
:global(.in-dropdown:active) {
|
||||
background-color: var(--list-cell-bg-hover);
|
||||
}
|
||||
|
||||
// Style dropdown items
|
||||
:global(.sv-item) {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
gap: spacing.$unit;
|
||||
}
|
||||
|
||||
// Style the input text
|
||||
:global(.sv-input--text) {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
// Hide the separator bar between buttons
|
||||
:global(.sv-btn-separator) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
right: spacing.$unit-3x;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-tertiary);
|
||||
font-size: typography.$font-small;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.roster-section {
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: spacing.$unit;
|
||||
}
|
||||
}
|
||||
|
||||
.roster-grid {
|
||||
overflow-x: auto;
|
||||
font-size: typography.$font-regular;
|
||||
}
|
||||
|
||||
.roster-header,
|
||||
.roster-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roster-header {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.roster-row {
|
||||
padding: spacing.$unit spacing.$unit-2x;
|
||||
border-radius: layout.$card-corner;
|
||||
transition: background-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: var(--list-cell-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.member-col {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.member-name {
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.item-col {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-item-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-half;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: typography.$medium;
|
||||
}
|
||||
|
||||
.header-item-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.remove-item-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--surface-hover, #e5e5e5);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-red, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ownership-cell {
|
||||
.not-owned {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-roster,
|
||||
.empty-roster {
|
||||
padding: spacing.$unit-3x;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue