extract MemberRow and PhantomRow components
This commit is contained in:
parent
07d276e469
commit
32eab5bcae
2 changed files with 374 additions and 0 deletions
197
src/lib/components/crew/MemberRow.svelte
Normal file
197
src/lib/components/crew/MemberRow.svelte
Normal 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>
|
||||||
177
src/lib/components/crew/PhantomRow.svelte
Normal file
177
src/lib/components/crew/PhantomRow.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue