add invite to crew action on user profiles
officers can invite users without a crew via context menu
This commit is contained in:
parent
d2c16d908d
commit
013c1b5eb2
2 changed files with 130 additions and 5 deletions
|
|
@ -1,19 +1,56 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
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 {
|
interface Props {
|
||||||
username: string
|
username: string
|
||||||
|
userId?: string
|
||||||
avatarPicture?: string
|
avatarPicture?: string
|
||||||
title?: string
|
title?: string
|
||||||
activeTab: 'teams' | 'favorites' | 'collection'
|
activeTab: 'teams' | 'favorites' | 'collection'
|
||||||
isOwner?: boolean
|
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 avatarSrc = $derived(getAvatarSrc(avatarPicture))
|
||||||
const avatarSrcSet = $derived(getAvatarSrcSet(avatarPicture))
|
const avatarSrcSet = $derived(getAvatarSrcSet(avatarPicture))
|
||||||
const displayTitle = $derived(title || username)
|
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>
|
</script>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
|
@ -57,8 +94,40 @@
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
|
{#if canInvite && userId && viewerCrewId}
|
||||||
|
<InviteUserModal
|
||||||
|
bind:open={inviteModalOpen}
|
||||||
|
{userId}
|
||||||
|
{username}
|
||||||
|
crewId={viewerCrewId}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use '$src/themes/spacing' as *;
|
@use '$src/themes/spacing' as *;
|
||||||
@use '$src/themes/colors' as *;
|
@use '$src/themes/colors' as *;
|
||||||
|
|
@ -113,4 +182,51 @@
|
||||||
color: var(--accent-color, #3366ff);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||||
import ProfileHeader from '$lib/components/profile/ProfileHeader.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 { IsInViewport } from 'runed'
|
||||||
import Icon from '$lib/components/Icon.svelte'
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
import Button from '$lib/components/ui/Button.svelte'
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
|
@ -13,9 +14,13 @@
|
||||||
const isOwner = $derived(data.isOwner || false)
|
const isOwner = $derived(data.isOwner || false)
|
||||||
const activeTab = $derived<'teams' | 'favorites'>(tab === 'favorites' ? 'favorites' : 'teams')
|
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
|
// Note: Type assertion needed because favorites and parties queries have different
|
||||||
// result structures (items vs results) but we handle both in the items $derived
|
// result structures (items vs results) but we handle both in the items $derived
|
||||||
const partiesQuery = createInfiniteQuery(() => {
|
const getQueryOptions = () => {
|
||||||
const isFavorites = tab === 'favorites' && isOwner
|
const isFavorites = tab === 'favorites' && isOwner
|
||||||
|
|
||||||
if (isFavorites) {
|
if (isFavorites) {
|
||||||
|
|
@ -57,8 +62,9 @@
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
initialDataUpdatedAt: 0
|
initialDataUpdatedAt: 0
|
||||||
} as unknown as ReturnType<typeof userQueries.favorites>
|
}
|
||||||
})
|
}
|
||||||
|
const partiesQuery = createInfiniteQuery(getQueryOptions as () => ReturnType<typeof userQueries.favorites>)
|
||||||
|
|
||||||
const items = $derived(() => {
|
const items = $derived(() => {
|
||||||
if (!partiesQuery.data?.pages) return data.items || []
|
if (!partiesQuery.data?.pages) return data.items || []
|
||||||
|
|
@ -93,9 +99,12 @@
|
||||||
<section class="profile">
|
<section class="profile">
|
||||||
<ProfileHeader
|
<ProfileHeader
|
||||||
username={data.user.username}
|
username={data.user.username}
|
||||||
|
userId={data.user?.id}
|
||||||
avatarPicture={data.user?.avatar?.picture}
|
avatarPicture={data.user?.avatar?.picture}
|
||||||
{activeTab}
|
{activeTab}
|
||||||
{isOwner}
|
{isOwner}
|
||||||
|
{viewerCrewRole}
|
||||||
|
{viewerCrewId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if partiesQuery.isLoading}
|
{#if partiesQuery.isLoading}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue