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:
Justin Edmund 2026-01-05 02:39:10 -08:00 committed by GitHub
parent a6d77a4463
commit b5d30997b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 373 additions and 8 deletions

View file

@ -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)
*/ */

View file

@ -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
*/ */

View file

@ -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() })
}
}))
}

View file

@ -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

View file

@ -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)
}
}
}
} }
}) })
} }

View file

@ -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>

View file

@ -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

View 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
}

View file

@ -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>