Party sharing frontend (#455)
## Summary - Add visibility dropdown (Public/Private/Unlisted) to party edit sidebar - Add "Share with Crew" switch for crew members - Implement /crew/teams page showing parties shared with crew - Add API layer for party shares and crew shared parties ## Test plan - [ ] Open party settings, verify visibility dropdown appears - [ ] For users in a crew, verify "Share with Crew" switch appears - [ ] Toggle crew share on/off, verify it persists - [ ] Navigate to /crew/teams, verify shared parties load - [ ] Test infinite scroll pagination on crew teams page
This commit is contained in:
parent
a6d77a4463
commit
b5d30997b8
9 changed files with 373 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { BaseAdapter } from './base.adapter'
|
import { BaseAdapter } from './base.adapter'
|
||||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||||
import type { RequestOptions } from './types'
|
import type { RequestOptions, PaginatedResponse } from './types'
|
||||||
|
import type { Party } from '$lib/types/api/party'
|
||||||
import type {
|
import type {
|
||||||
Crew,
|
Crew,
|
||||||
CrewMembership,
|
CrewMembership,
|
||||||
|
|
@ -31,6 +32,23 @@ export class CrewAdapter extends BaseAdapter {
|
||||||
return response.crew
|
return response.crew
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parties shared with the user's crew
|
||||||
|
*/
|
||||||
|
async getSharedParties(
|
||||||
|
page = 1,
|
||||||
|
perPage = 20,
|
||||||
|
options?: RequestOptions
|
||||||
|
): Promise<{ parties: Party[]; meta: { page: number; totalPages: number; count: number; perPage: number } }> {
|
||||||
|
return this.request<{ parties: Party[]; meta: { page: number; totalPages: number; count: number; perPage: number } }>(
|
||||||
|
'/crew/shared_parties',
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
params: { page, per_page: perPage }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new crew (user becomes captain)
|
* Create a new crew (user becomes captain)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { BaseAdapter } from './base.adapter'
|
||||||
import type { AdapterOptions, PaginatedResponse } from './types'
|
import type { AdapterOptions, PaginatedResponse } from './types'
|
||||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||||
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
||||||
|
import type { PartyShare } from '$lib/types/api/partyShare'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for creating a new party
|
* Parameters for creating a new party
|
||||||
|
|
@ -409,6 +410,28 @@ export class PartyAdapter extends BaseAdapter {
|
||||||
this.clearCache(`/parties/${shortcode}`)
|
this.clearCache(`/parties/${shortcode}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a party with the current user's crew
|
||||||
|
* @param partyId - The party's UUID
|
||||||
|
*/
|
||||||
|
async shareWithCrew(partyId: string): Promise<PartyShare> {
|
||||||
|
const response = await this.request<{ share: PartyShare }>(`/parties/${partyId}/shares`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
return response.share
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a party share
|
||||||
|
* @param partyId - The party's UUID
|
||||||
|
* @param shareId - The share's UUID to remove
|
||||||
|
*/
|
||||||
|
async removeShare(partyId: string, shareId: string): Promise<void> {
|
||||||
|
await this.request(`/parties/${partyId}/shares/${shareId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the cache for party-related data
|
* Clears the cache for party-related data
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '$lib/api/adapters/party.adapter'
|
} from '$lib/api/adapters/party.adapter'
|
||||||
import { partyKeys } from '$lib/api/queries/party.queries'
|
import { partyKeys } from '$lib/api/queries/party.queries'
|
||||||
import { userKeys } from '$lib/api/queries/user.queries'
|
import { userKeys } from '$lib/api/queries/user.queries'
|
||||||
|
import { crewKeys } from '$lib/api/queries/crew.queries'
|
||||||
import type { Party } from '$lib/types/api/party'
|
import type { Party } from '$lib/types/api/party'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -312,3 +313,69 @@ export function useRegeneratePreview() {
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share party with crew mutation
|
||||||
|
*
|
||||||
|
* Shares a party with the current user's crew.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { useSharePartyWithCrew } from '$lib/api/mutations/party.mutations'
|
||||||
|
*
|
||||||
|
* const shareParty = useSharePartyWithCrew()
|
||||||
|
*
|
||||||
|
* function handleShare(partyId: string, shortcode: string) {
|
||||||
|
* shareParty.mutate({ partyId, shortcode })
|
||||||
|
* }
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSharePartyWithCrew() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return createMutation(() => ({
|
||||||
|
mutationFn: ({ partyId }: { partyId: string; shortcode: string }) =>
|
||||||
|
partyAdapter.shareWithCrew(partyId),
|
||||||
|
onSuccess: (_share, { shortcode }) => {
|
||||||
|
// Invalidate the party to refresh its shares
|
||||||
|
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
|
||||||
|
// Invalidate crew's shared parties list
|
||||||
|
queryClient.invalidateQueries({ queryKey: crewKeys.sharedParties() })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove party share mutation
|
||||||
|
*
|
||||||
|
* Removes a share from a party.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { useRemovePartyShare } from '$lib/api/mutations/party.mutations'
|
||||||
|
*
|
||||||
|
* const removeShare = useRemovePartyShare()
|
||||||
|
*
|
||||||
|
* function handleRemoveShare(partyId: string, shareId: string, shortcode: string) {
|
||||||
|
* removeShare.mutate({ partyId, shareId, shortcode })
|
||||||
|
* }
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useRemovePartyShare() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return createMutation(() => ({
|
||||||
|
mutationFn: ({ partyId, shareId }: { partyId: string; shareId: string; shortcode: string }) =>
|
||||||
|
partyAdapter.removeShare(partyId, shareId),
|
||||||
|
onSuccess: (_data, { shortcode }) => {
|
||||||
|
// Invalidate the party to refresh its shares
|
||||||
|
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
|
||||||
|
// Invalidate crew's shared parties list
|
||||||
|
queryClient.invalidateQueries({ queryKey: crewKeys.sharedParties() })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* @module api/queries/crew
|
* @module api/queries/crew
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { queryOptions } from '@tanstack/svelte-query'
|
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
|
||||||
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
||||||
import type { MemberFilter } from '$lib/types/api/crew'
|
import type { MemberFilter } from '$lib/types/api/crew'
|
||||||
|
|
||||||
|
|
@ -96,6 +96,30 @@ export const crewQueries = {
|
||||||
queryFn: () => crewAdapter.getPendingPhantomClaims(),
|
queryFn: () => crewAdapter.getPendingPhantomClaims(),
|
||||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||||
gcTime: 1000 * 60 * 15 // 15 minutes
|
gcTime: 1000 * 60 * 15 // 15 minutes
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parties shared with the crew query options
|
||||||
|
* Uses infinite query for pagination
|
||||||
|
*/
|
||||||
|
sharedParties: () =>
|
||||||
|
infiniteQueryOptions({
|
||||||
|
queryKey: ['crew', 'shared_parties'] as const,
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const response = await crewAdapter.getSharedParties(pageParam)
|
||||||
|
return {
|
||||||
|
parties: response.parties,
|
||||||
|
page: response.meta.page,
|
||||||
|
totalPages: response.meta.totalPages,
|
||||||
|
total: response.meta.count,
|
||||||
|
perPage: response.meta.perPage
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||||
|
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||||
|
gcTime: 1000 * 60 * 15 // 15 minutes
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +146,7 @@ export const crewKeys = {
|
||||||
membersAll: () => [...crewKeys.all, 'members'] as const,
|
membersAll: () => [...crewKeys.all, 'members'] as const,
|
||||||
members: (filter: MemberFilter) => [...crewKeys.all, 'members', filter] as const,
|
members: (filter: MemberFilter) => [...crewKeys.all, 'members', filter] as const,
|
||||||
crewInvitations: (crewId: string) => [...crewKeys.all, crewId, 'invitations'] as const,
|
crewInvitations: (crewId: string) => [...crewKeys.all, crewId, 'invitations'] as const,
|
||||||
|
sharedParties: () => [...crewKeys.all, 'shared_parties'] as const,
|
||||||
invitations: {
|
invitations: {
|
||||||
all: ['invitations'] as const,
|
all: ['invitations'] as const,
|
||||||
pending: () => ['invitations', 'pending'] as const
|
pending: () => ['invitations', 'pending'] as const
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@
|
||||||
useDeleteParty,
|
useDeleteParty,
|
||||||
useRemixParty,
|
useRemixParty,
|
||||||
useFavoriteParty,
|
useFavoriteParty,
|
||||||
useUnfavoriteParty
|
useUnfavoriteParty,
|
||||||
|
useSharePartyWithCrew,
|
||||||
|
useRemovePartyShare
|
||||||
} from '$lib/api/mutations/party.mutations'
|
} from '$lib/api/mutations/party.mutations'
|
||||||
|
|
||||||
// TanStack Query mutations - Job
|
// TanStack Query mutations - Job
|
||||||
|
|
@ -158,6 +160,8 @@
|
||||||
const remixPartyMutation = useRemixParty()
|
const remixPartyMutation = useRemixParty()
|
||||||
const favoritePartyMutation = useFavoriteParty()
|
const favoritePartyMutation = useFavoriteParty()
|
||||||
const unfavoritePartyMutation = useUnfavoriteParty()
|
const unfavoritePartyMutation = useUnfavoriteParty()
|
||||||
|
const sharePartyWithCrewMutation = useSharePartyWithCrew()
|
||||||
|
const removePartyShareMutation = useRemovePartyShare()
|
||||||
|
|
||||||
// TanStack Query mutations - Job
|
// TanStack Query mutations - Job
|
||||||
const updateJobMutation = useUpdatePartyJob()
|
const updateJobMutation = useUpdatePartyJob()
|
||||||
|
|
@ -443,9 +447,14 @@
|
||||||
function openSettingsPanel() {
|
function openSettingsPanel() {
|
||||||
if (!canEdit()) return
|
if (!canEdit()) return
|
||||||
|
|
||||||
|
// Check if party is currently shared with a crew
|
||||||
|
const isSharedWithCrew = party.shares?.some((s) => s.shareableType === 'crew') ?? false
|
||||||
|
|
||||||
const initialValues: PartyEditValues = {
|
const initialValues: PartyEditValues = {
|
||||||
name: party.name ?? '',
|
name: party.name ?? '',
|
||||||
description: party.description ?? null,
|
description: party.description ?? null,
|
||||||
|
visibility: party.visibility ?? 'public',
|
||||||
|
sharedWithCrew: isSharedWithCrew,
|
||||||
fullAuto: party.fullAuto ?? false,
|
fullAuto: party.fullAuto ?? false,
|
||||||
autoGuard: party.autoGuard ?? false,
|
autoGuard: party.autoGuard ?? false,
|
||||||
autoSummon: party.autoSummon ?? false,
|
autoSummon: party.autoSummon ?? false,
|
||||||
|
|
@ -463,9 +472,11 @@
|
||||||
initialValues,
|
initialValues,
|
||||||
element: userElement,
|
element: userElement,
|
||||||
onSave: async (values) => {
|
onSave: async (values) => {
|
||||||
|
// Update party details including visibility
|
||||||
await updatePartyDetails({
|
await updatePartyDetails({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
|
visibility: values.visibility,
|
||||||
fullAuto: values.fullAuto,
|
fullAuto: values.fullAuto,
|
||||||
autoGuard: values.autoGuard,
|
autoGuard: values.autoGuard,
|
||||||
autoSummon: values.autoSummon,
|
autoSummon: values.autoSummon,
|
||||||
|
|
@ -477,6 +488,34 @@
|
||||||
videoUrl: values.videoUrl,
|
videoUrl: values.videoUrl,
|
||||||
raidId: values.raidId
|
raidId: values.raidId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle crew share toggle
|
||||||
|
const wasShared = party.shares?.some((s) => s.shareableType === 'crew') ?? false
|
||||||
|
if (values.sharedWithCrew && !wasShared) {
|
||||||
|
// Share with crew
|
||||||
|
try {
|
||||||
|
await sharePartyWithCrewMutation.mutateAsync({
|
||||||
|
partyId: party.id,
|
||||||
|
shortcode: party.shortcode
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to share with crew:', err)
|
||||||
|
}
|
||||||
|
} else if (!values.sharedWithCrew && wasShared) {
|
||||||
|
// Remove crew share
|
||||||
|
const share = party.shares?.find((s) => s.shareableType === 'crew')
|
||||||
|
if (share) {
|
||||||
|
try {
|
||||||
|
await removePartyShareMutation.mutateAsync({
|
||||||
|
partyId: party.id,
|
||||||
|
shareId: share.id,
|
||||||
|
shortcode: party.shortcode
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to remove share:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,22 @@
|
||||||
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
||||||
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.svelte'
|
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.svelte'
|
||||||
import EditDescriptionPane from '$lib/components/sidebar/EditDescriptionPane.svelte'
|
import EditDescriptionPane from '$lib/components/sidebar/EditDescriptionPane.svelte'
|
||||||
|
import Select from '$lib/components/ui/Select.svelte'
|
||||||
|
import Switch from '$lib/components/ui/switch/Switch.svelte'
|
||||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
||||||
|
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Raid } from '$lib/types/api/entities'
|
import type { Raid } from '$lib/types/api/entities'
|
||||||
import type { RaidFull } from '$lib/types/api/raid'
|
import type { RaidFull } from '$lib/types/api/raid'
|
||||||
|
import type { PartyVisibility } from '$lib/types/visibility'
|
||||||
import Icon from '$lib/components/Icon.svelte'
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
export interface PartyEditValues {
|
export interface PartyEditValues {
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
|
visibility: PartyVisibility
|
||||||
|
sharedWithCrew: boolean
|
||||||
fullAuto: boolean
|
fullAuto: boolean
|
||||||
autoGuard: boolean
|
autoGuard: boolean
|
||||||
autoSummon: boolean
|
autoSummon: boolean
|
||||||
|
|
@ -55,6 +61,8 @@
|
||||||
|
|
||||||
// Local state - initialized from initialValues
|
// Local state - initialized from initialValues
|
||||||
let name = $state(initialValues.name)
|
let name = $state(initialValues.name)
|
||||||
|
let visibility = $state<PartyVisibility>(initialValues.visibility)
|
||||||
|
let sharedWithCrew = $state(initialValues.sharedWithCrew)
|
||||||
let fullAuto = $state(initialValues.fullAuto)
|
let fullAuto = $state(initialValues.fullAuto)
|
||||||
let autoGuard = $state(initialValues.autoGuard)
|
let autoGuard = $state(initialValues.autoGuard)
|
||||||
let autoSummon = $state(initialValues.autoSummon)
|
let autoSummon = $state(initialValues.autoSummon)
|
||||||
|
|
@ -68,9 +76,18 @@
|
||||||
let raidId = $state<string | null>(initialValues.raidId)
|
let raidId = $state<string | null>(initialValues.raidId)
|
||||||
let description = $state(initialValues.description)
|
let description = $state(initialValues.description)
|
||||||
|
|
||||||
|
// Visibility options for select
|
||||||
|
const visibilityOptions: Array<{ value: PartyVisibility; label: string }> = [
|
||||||
|
{ value: 'public', label: 'Public' },
|
||||||
|
{ value: 'private', label: 'Private' },
|
||||||
|
{ value: 'unlisted', label: 'Unlisted' }
|
||||||
|
]
|
||||||
|
|
||||||
// Check if any values have changed
|
// Check if any values have changed
|
||||||
const hasChanges = $derived(
|
const hasChanges = $derived(
|
||||||
name !== initialValues.name ||
|
name !== initialValues.name ||
|
||||||
|
visibility !== initialValues.visibility ||
|
||||||
|
sharedWithCrew !== initialValues.sharedWithCrew ||
|
||||||
fullAuto !== initialValues.fullAuto ||
|
fullAuto !== initialValues.fullAuto ||
|
||||||
autoGuard !== initialValues.autoGuard ||
|
autoGuard !== initialValues.autoGuard ||
|
||||||
autoSummon !== initialValues.autoSummon ||
|
autoSummon !== initialValues.autoSummon ||
|
||||||
|
|
@ -89,6 +106,8 @@
|
||||||
const values: PartyEditValues = {
|
const values: PartyEditValues = {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
visibility,
|
||||||
|
sharedWithCrew,
|
||||||
fullAuto,
|
fullAuto,
|
||||||
autoGuard,
|
autoGuard,
|
||||||
autoSummon,
|
autoSummon,
|
||||||
|
|
@ -264,6 +283,30 @@
|
||||||
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DetailsSection title="Sharing">
|
||||||
|
<DetailRow label="Visibility" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Select
|
||||||
|
options={visibilityOptions}
|
||||||
|
bind:value={visibility}
|
||||||
|
size="small"
|
||||||
|
contained
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
{#if crewStore.isInCrew}
|
||||||
|
<DetailRow label="Share with Crew" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Switch
|
||||||
|
bind:checked={sharedWithCrew}
|
||||||
|
size="small"
|
||||||
|
{element}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
{/if}
|
||||||
|
</DetailsSection>
|
||||||
|
|
||||||
<button type="button" class="description-button" onclick={openDescriptionPane}>
|
<button type="button" class="description-button" onclick={openDescriptionPane}>
|
||||||
<div class="description-header">
|
<div class="description-header">
|
||||||
<span class="description-label">Description</span>
|
<span class="description-label">Description</span>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
} from './entities'
|
} from './entities'
|
||||||
import type { GridArtifact, CollectionArtifact } from './artifact'
|
import type { GridArtifact, CollectionArtifact } from './artifact'
|
||||||
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
|
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
|
||||||
|
import type { PartyShare } from './partyShare'
|
||||||
|
|
||||||
// Grid item types - these are the junction tables between Party and entities
|
// Grid item types - these are the junction tables between Party and entities
|
||||||
|
|
||||||
|
|
@ -139,6 +140,8 @@ export interface Party {
|
||||||
user?: User
|
user?: User
|
||||||
sourceParty?: Party
|
sourceParty?: Party
|
||||||
remixes?: Party[]
|
remixes?: Party[]
|
||||||
|
/** Shares for this party (only present for owner) */
|
||||||
|
shares?: PartyShare[]
|
||||||
|
|
||||||
// Local client state
|
// Local client state
|
||||||
localId?: string
|
localId?: string
|
||||||
|
|
|
||||||
10
src/lib/types/api/partyShare.ts
Normal file
10
src/lib/types/api/partyShare.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// PartyShare type for party sharing with crews/groups
|
||||||
|
// Based on PartyShareBlueprint from Rails API
|
||||||
|
|
||||||
|
export interface PartyShare {
|
||||||
|
id: string
|
||||||
|
shareableType: string
|
||||||
|
shareableId: string
|
||||||
|
shareableName?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
<svelte:options runes={true} />
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||||
|
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||||
|
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
|
||||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||||
import CrewTabs from '$lib/components/crew/CrewTabs.svelte'
|
import CrewTabs from '$lib/components/crew/CrewTabs.svelte'
|
||||||
|
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -11,6 +18,30 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props()
|
let { data }: Props = $props()
|
||||||
|
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
|
// Query for shared parties
|
||||||
|
const sharedPartiesQuery = createInfiniteQuery(() => ({
|
||||||
|
...crewQueries.sharedParties(),
|
||||||
|
enabled: crewStore.isInCrew
|
||||||
|
}))
|
||||||
|
|
||||||
|
// State-gated infinite scroll
|
||||||
|
const loader = useInfiniteLoader(
|
||||||
|
() => sharedPartiesQuery,
|
||||||
|
() => sentinelEl,
|
||||||
|
{ rootMargin: '300px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup on destroy
|
||||||
|
onDestroy(() => loader.destroy())
|
||||||
|
|
||||||
|
const items = $derived(
|
||||||
|
sharedPartiesQuery.data?.pages.flatMap((page) => page.parties) ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEmpty = $derived(!sharedPartiesQuery.isLoading && items.length === 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -27,8 +58,53 @@
|
||||||
|
|
||||||
<CrewTabs userElement={data.currentUser?.element} />
|
<CrewTabs userElement={data.currentUser?.element} />
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{#if !crewStore.isInCrew}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>Coming soon</p>
|
<Icon name="users" size={32} />
|
||||||
|
<p>You're not in a crew</p>
|
||||||
|
</div>
|
||||||
|
{:else if sharedPartiesQuery.isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Icon name="loader-2" size={32} class="spin" />
|
||||||
|
<p>Loading shared teams...</p>
|
||||||
|
</div>
|
||||||
|
{:else if sharedPartiesQuery.isError}
|
||||||
|
<div class="error-state">
|
||||||
|
<Icon name="alert-circle" size={32} />
|
||||||
|
<p>Failed to load teams: {sharedPartiesQuery.error?.message || 'Unknown error'}</p>
|
||||||
|
<Button size="small" onclick={() => sharedPartiesQuery.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
{:else if isEmpty}
|
||||||
|
<div class="empty-state">
|
||||||
|
<Icon name="share-2" size={32} />
|
||||||
|
<p>No teams have been shared with your crew yet</p>
|
||||||
|
<span class="hint">Crew members can share their teams from the team settings</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="teams-grid">
|
||||||
|
<ExploreGrid {items} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="load-more-sentinel"
|
||||||
|
bind:this={sentinelEl}
|
||||||
|
class:hidden={!sharedPartiesQuery.hasNextPage}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{#if sharedPartiesQuery.isFetchingNextPage}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} class="spin" />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !sharedPartiesQuery.hasNextPage && items.length > 0}
|
||||||
|
<div class="end-message">
|
||||||
|
<p>You've seen all shared teams</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,17 +128,78 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.content {
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: spacing.$unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state,
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: spacing.$unit-4x;
|
padding: spacing.$unit-6x spacing.$unit-4x;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: spacing.$unit;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: spacing.$unit-2x;
|
||||||
|
color: var(--text-tertiary);
|
||||||
font-size: typography.$font-small;
|
font-size: typography.$font-small;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.spin) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue