extract MemberRow and PhantomRow components

This commit is contained in:
Justin Edmund 2025-12-16 14:45:23 -08:00
parent 07d276e469
commit 32eab5bcae
2 changed files with 374 additions and 0 deletions

View file

@ -0,0 +1,197 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import Button from '$lib/components/ui/Button.svelte'
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import { crewStore } from '$lib/stores/crew.store.svelte'
import type { CrewMembership } from '$lib/types/api/crew'
interface Props {
member: CrewMembership
onEdit?: () => void
onPromote?: () => void
onDemote?: () => void
onRemove?: () => void
}
const { member, onEdit, onPromote, onDemote, onRemove }: Props = $props()
function getRoleLabel(role: string): string {
switch (role) {
case 'captain':
return 'Captain'
case 'vice_captain':
return 'Vice Captain'
default:
return 'Member'
}
}
function getRoleClass(role: string): string {
switch (role) {
case 'captain':
return 'captain'
case 'vice_captain':
return 'officer'
default:
return ''
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const canShowOfficerActions = $derived(
crewStore.isOfficer &&
crewStore.canActOnMember(member.role) &&
!member.retired &&
member.id !== crewStore.membership?.id
)
const canPromote = $derived(member.role === 'member' && crewStore.canPromoteTo('vice_captain'))
const canDemote = $derived(
member.role === 'vice_captain' && crewStore.canDemote('vice_captain')
)
</script>
<li class="member-row" class:retired={member.retired}>
<div class="member-info">
<div class="member-details">
{#if member.user?.username}
<a href="/{member.user.username}" class="username">{member.user.username}</a>
{:else}
<span class="username">Unknown</span>
{/if}
{#if member.joinedAt}
<span class="joined-date">Joined {formatDate(member.joinedAt)}</span>
{/if}
</div>
</div>
<div class="member-actions">
<span class="role-badge {getRoleClass(member.role)}">
{getRoleLabel(member.role)}
</span>
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
{#if member.user?.username}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => goto(`/${member.user?.username}`)}
>
View profile
</DropdownMenuBase.Item>
{/if}
{#if crewStore.isOfficer && onEdit}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onEdit}>
Edit
</DropdownMenuBase.Item>
{/if}
{#if canShowOfficerActions}
{#if canPromote && onPromote}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onPromote}>
Promote
</DropdownMenuBase.Item>
{/if}
{#if canDemote && onDemote}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onDemote}>
Demote
</DropdownMenuBase.Item>
{/if}
{#if onRemove}
<DropdownMenuBase.Item class="dropdown-menu-item danger" onclick={onRemove}>
Remove
</DropdownMenuBase.Item>
{/if}
{/if}
{/snippet}
</DropdownMenu>
</div>
</li>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.member-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&.retired {
opacity: 0.6;
}
}
.member-info {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.member-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.member-actions {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.username {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
text-decoration: none;
}
a.username:hover {
text-decoration: underline;
}
.role-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
font-weight: typography.$medium;
background: rgba(0, 0, 0, 0.04);
&.captain {
background: var(--color-gold-light, #fef3c7);
color: var(--color-gold-dark, #92400e);
}
&.officer {
background: var(--color-blue-light, #dbeafe);
color: var(--color-blue-dark, #1e40af);
}
}
.joined-date {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
</style>

View file

@ -0,0 +1,177 @@
<svelte:options runes={true} />
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte'
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import { crewStore } from '$lib/stores/crew.store.svelte'
import type { PhantomPlayer } from '$lib/types/api/crew'
interface Props {
phantom: PhantomPlayer
currentUserId?: string
onEdit?: () => void
onDelete?: () => void
onAssign?: () => void
onConfirmClaim?: () => void
}
const { phantom, currentUserId, onEdit, onDelete, onAssign, onConfirmClaim }: Props = $props()
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
// Status badge type
type ClaimStatus = 'unclaimed' | 'pending' | 'claimed'
const claimStatus = $derived.by((): ClaimStatus => {
if (phantom.claimConfirmed && phantom.claimedBy) return 'claimed'
if (phantom.claimedBy) return 'pending'
return 'unclaimed'
})
// Check if current user is the one assigned to this phantom (can claim)
const canClaim = $derived(
phantom.claimedBy?.id === currentUserId && !phantom.claimConfirmed
)
</script>
<li class="phantom-row" class:retired={phantom.retired}>
<div class="phantom-info">
<div class="phantom-details">
<span class="name">{phantom.name}</span>
{#if phantom.granblueId}
<span class="granblue-id">ID: {phantom.granblueId}</span>
{/if}
{#if phantom.joinedAt}
<span class="joined-date">Joined {formatDate(phantom.joinedAt)}</span>
{/if}
</div>
</div>
<div class="phantom-actions">
{#if claimStatus === 'unclaimed'}
<span class="status-badge unclaimed">Unclaimed</span>
{:else if claimStatus === 'pending'}
<span class="status-badge pending">Pending: {phantom.claimedBy?.username}</span>
{:else if claimStatus === 'claimed'}
<span class="status-badge claimed">Claimed by {phantom.claimedBy?.username}</span>
{/if}
{#if crewStore.isOfficer}
<!-- Officers get dropdown menu -->
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
{#if onEdit}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onEdit}>
Edit
</DropdownMenuBase.Item>
{/if}
{#if claimStatus === 'unclaimed' && onAssign}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onAssign}>
Assign to...
</DropdownMenuBase.Item>
{/if}
{#if onDelete}
<DropdownMenuBase.Separator class="dropdown-menu-separator" />
<DropdownMenuBase.Item class="dropdown-menu-item danger" onclick={onDelete}>
Delete
</DropdownMenuBase.Item>
{/if}
{/snippet}
</DropdownMenu>
{:else if canClaim && onConfirmClaim}
<!-- Non-officers who can claim get a simple button -->
<Button variant="secondary" size="small" onclick={onConfirmClaim}>
Claim
</Button>
{/if}
</div>
</li>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.phantom-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&.retired {
opacity: 0.6;
}
}
.phantom-info {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.phantom-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.name {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
}
.granblue-id {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.joined-date {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.phantom-actions {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
&.unclaimed {
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
&.pending {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
&.claimed {
background: var(--color-green-light, #dcfce7);
color: var(--color-green-dark, #166534);
}
}
</style>