add invite to crew action on user profiles

officers can invite users without a crew via context menu
This commit is contained in:
Justin Edmund 2025-12-13 18:07:11 -08:00
parent d2c16d908d
commit 013c1b5eb2
2 changed files with 130 additions and 5 deletions

View file

@ -1,19 +1,56 @@
<script lang="ts">
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { DropdownMenu } from 'bits-ui'
import Icon from '$lib/components/Icon.svelte'
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
import InviteUserModal from '$lib/components/crew/InviteUserModal.svelte'
import type { CrewRole } from '$lib/types/api/crew'
interface Props {
username: string
userId?: string
avatarPicture?: string
title?: string
activeTab: 'teams' | 'favorites' | 'collection'
isOwner?: boolean
/** Current user's crew role (null if not in a crew) */
viewerCrewRole?: CrewRole | null
/** Current user's crew ID */
viewerCrewId?: string | null
/** Whether the target user is in a crew */
targetUserHasCrew?: boolean
}
let { username, avatarPicture = '', title, activeTab, isOwner = false }: Props = $props()
let {
username,
userId,
avatarPicture = '',
title,
activeTab,
isOwner = false,
viewerCrewRole = null,
viewerCrewId = null,
targetUserHasCrew = false
}: Props = $props()
const avatarSrc = $derived(getAvatarSrc(avatarPicture))
const avatarSrcSet = $derived(getAvatarSrcSet(avatarPicture))
const displayTitle = $derived(title || username)
// Can invite if: viewer is captain/vice_captain AND target user is not in a crew AND not viewing own profile
const canInvite = $derived(
!isOwner &&
viewerCrewRole !== null &&
(viewerCrewRole === 'captain' || viewerCrewRole === 'vice_captain') &&
!targetUserHasCrew &&
userId
)
// Show menu if there are any actions available
const showMenu = $derived(canInvite)
// Invite modal state
let inviteModalOpen = $state(false)
</script>
<header class="header">
@ -57,8 +94,40 @@
</a>
</nav>
</div>
{#if showMenu}
<div class="header-actions">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="menu-trigger">
<Icon name="ellipsis" size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-content" sideOffset={5} align="end">
{#if canInvite}
<DropdownItem>
<button onclick={() => (inviteModalOpen = true)}>
<Icon name="user-plus" size={14} />
<span>Invite to Crew</span>
</button>
</DropdownItem>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/if}
</header>
{#if canInvite && userId && viewerCrewId}
<InviteUserModal
bind:open={inviteModalOpen}
{userId}
{username}
crewId={viewerCrewId}
/>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@ -113,4 +182,51 @@
color: var(--accent-color, #3366ff);
}
}
.header-actions {
margin-left: auto;
flex-shrink: 0;
}
:global(.menu-trigger) {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--button-contained-bg-hover, $grey-90);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
}
:global(.dropdown-content) {
background-color: var(--menu-bg);
border-radius: 8px;
padding: $unit-half;
min-width: 160px;
box-shadow:
0 10px 38px -10px rgba(22, 23, 24, 0.35),
0 10px 20px -15px rgba(22, 23, 24, 0.2);
z-index: 50;
button {
display: flex;
align-items: center;
gap: $unit-half;
width: 100%;
}
}
</style>

View file

@ -3,7 +3,8 @@
import { createInfiniteQuery } from '@tanstack/svelte-query'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import ProfileHeader from '$lib/components/profile/ProfileHeader.svelte'
import { userQueries } from '$lib/api/queries/user.queries'
import { userQueries, type FavoritesPageResult } from '$lib/api/queries/user.queries'
import { crewStore } from '$lib/stores/crew.store.svelte'
import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
@ -13,9 +14,13 @@
const isOwner = $derived(data.isOwner || false)
const activeTab = $derived<'teams' | 'favorites'>(tab === 'favorites' ? 'favorites' : 'teams')
// Crew info for invite functionality
const viewerCrewRole = $derived(crewStore.membership?.role ?? null)
const viewerCrewId = $derived(crewStore.crew?.id ?? null)
// Note: Type assertion needed because favorites and parties queries have different
// result structures (items vs results) but we handle both in the items $derived
const partiesQuery = createInfiniteQuery(() => {
const getQueryOptions = () => {
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
@ -57,8 +62,9 @@
}
: undefined,
initialDataUpdatedAt: 0
} as unknown as ReturnType<typeof userQueries.favorites>
})
}
}
const partiesQuery = createInfiniteQuery(getQueryOptions as () => ReturnType<typeof userQueries.favorites>)
const items = $derived(() => {
if (!partiesQuery.data?.pages) return data.items || []
@ -93,9 +99,12 @@
<section class="profile">
<ProfileHeader
username={data.user.username}
userId={data.user?.id}
avatarPicture={data.user?.avatar?.picture}
{activeTab}
{isOwner}
{viewerCrewRole}
{viewerCrewId}
/>
{#if partiesQuery.isLoading}