diff --git a/src/lib/api/adapters/crew.adapter.ts b/src/lib/api/adapters/crew.adapter.ts new file mode 100644 index 00000000..714b0201 --- /dev/null +++ b/src/lib/api/adapters/crew.adapter.ts @@ -0,0 +1,277 @@ +import { BaseAdapter } from './base.adapter' +import { DEFAULT_ADAPTER_CONFIG } from './config' +import type { RequestOptions } from './types' +import type { + Crew, + CrewMembership, + CrewMembersResponse, + CrewInvitation, + PhantomPlayer, + CreateCrewInput, + UpdateCrewInput, + CreatePhantomPlayerInput, + UpdatePhantomPlayerInput, + UpdateMembershipInput, + MemberFilter +} from '$lib/types/api/crew' + +/** + * Adapter for crew-related API operations + */ +export class CrewAdapter extends BaseAdapter { + // ==================== Crew Operations ==================== + + /** + * Get current user's crew + */ + async getMyCrew(options?: RequestOptions): Promise { + const response = await this.request<{ crew: Crew }>('/crew', options) + return response.crew + } + + /** + * Create a new crew (user becomes captain) + */ + async create(input: CreateCrewInput, options?: RequestOptions): Promise { + const response = await this.request<{ crew: Crew }>('/crews', { + ...options, + method: 'POST', + body: JSON.stringify({ crew: input }) + }) + return response.crew + } + + /** + * Update current user's crew (officers only) + */ + async update(input: UpdateCrewInput, options?: RequestOptions): Promise { + const response = await this.request<{ crew: Crew }>('/crew', { + ...options, + method: 'PUT', + body: JSON.stringify({ crew: input }) + }) + this.clearCache('/crew') + return response.crew + } + + /** + * Get crew members with optional filter + * @param filter - 'active' (default), 'retired', 'phantom', 'all' + */ + async getMembers(filter: MemberFilter = 'active', options?: RequestOptions): Promise { + const params = filter !== 'active' ? { filter } : undefined + return this.request('/crew/members', { ...options, params }) + } + + /** + * Leave current crew (not available for captain) + */ + async leave(options?: RequestOptions): Promise { + await this.request('/crew/leave', { + ...options, + method: 'POST' + }) + this.clearCache('/crew') + this.clearCache('/crew/members') + } + + /** + * Transfer captain role to another member (captain only) + */ + async transferCaptain(crewId: string, userId: string, options?: RequestOptions): Promise { + const response = await this.request<{ crew: Crew }>(`/crews/${crewId}/transfer_captain`, { + ...options, + method: 'POST', + body: JSON.stringify({ user_id: userId }) + }) + this.clearCache('/crew') + this.clearCache('/crew/members') + return response.crew + } + + // ==================== Membership Operations ==================== + + /** + * Update a member's role or joined_at (officers for joined_at, captain for role) + */ + async updateMembership( + crewId: string, + membershipId: string, + input: UpdateMembershipInput, + options?: RequestOptions + ): Promise { + const response = await this.request<{ membership: CrewMembership }>( + `/crews/${crewId}/memberships/${membershipId}`, + { + ...options, + method: 'PUT', + body: JSON.stringify({ membership: input }) + } + ) + this.clearCache('/crew/members') + return response.membership + } + + /** + * Remove a member from crew (officers only) + */ + async removeMember(crewId: string, membershipId: string, options?: RequestOptions): Promise { + await this.request(`/crews/${crewId}/memberships/${membershipId}`, { + ...options, + method: 'DELETE' + }) + this.clearCache('/crew/members') + } + + // ==================== Invitation Operations ==================== + + /** + * Send invitation to a user (officers only) + */ + async sendInvitation(crewId: string, userId: string, options?: RequestOptions): Promise { + const response = await this.request<{ invitation: CrewInvitation }>(`/crews/${crewId}/invitations`, { + ...options, + method: 'POST', + body: JSON.stringify({ user_id: userId }) + }) + return response.invitation + } + + /** + * Get crew's sent invitations + */ + async getCrewInvitations(crewId: string, options?: RequestOptions): Promise { + const response = await this.request<{ invitations: CrewInvitation[] }>( + `/crews/${crewId}/invitations`, + options + ) + return response.invitations + } + + /** + * Get current user's pending invitations + */ + async getPendingInvitations(options?: RequestOptions): Promise { + const response = await this.request<{ invitations: CrewInvitation[] }>('/invitations/pending', options) + return response.invitations + } + + /** + * Accept an invitation + */ + async acceptInvitation(invitationId: string, options?: RequestOptions): Promise { + const response = await this.request<{ membership: CrewMembership }>( + `/invitations/${invitationId}/accept`, + { + ...options, + method: 'POST' + } + ) + this.clearCache('/crew') + this.clearCache('/invitations/pending') + return response.membership + } + + /** + * Reject an invitation + */ + async rejectInvitation(invitationId: string, options?: RequestOptions): Promise { + await this.request(`/invitations/${invitationId}/reject`, { + ...options, + method: 'POST' + }) + this.clearCache('/invitations/pending') + } + + // ==================== Phantom Player Operations ==================== + + /** + * Create a phantom player (officers only) + */ + async createPhantom( + crewId: string, + input: CreatePhantomPlayerInput, + options?: RequestOptions + ): Promise { + const response = await this.request<{ phantom_player: PhantomPlayer }>( + `/crews/${crewId}/phantom_players`, + { + ...options, + method: 'POST', + body: JSON.stringify({ phantom_player: input }) + } + ) + this.clearCache('/crew/members') + return response.phantom_player + } + + /** + * Update a phantom player + */ + async updatePhantom( + crewId: string, + phantomId: string, + input: UpdatePhantomPlayerInput, + options?: RequestOptions + ): Promise { + const response = await this.request<{ phantom_player: PhantomPlayer }>( + `/crews/${crewId}/phantom_players/${phantomId}`, + { + ...options, + method: 'PUT', + body: JSON.stringify({ phantom_player: input }) + } + ) + this.clearCache('/crew/members') + return response.phantom_player + } + + /** + * Delete a phantom player + */ + async deletePhantom(crewId: string, phantomId: string, options?: RequestOptions): Promise { + await this.request(`/crews/${crewId}/phantom_players/${phantomId}`, { + ...options, + method: 'DELETE' + }) + this.clearCache('/crew/members') + } + + /** + * Assign a phantom player to a user (officers only) + */ + async assignPhantom( + crewId: string, + phantomId: string, + userId: string, + options?: RequestOptions + ): Promise { + const response = await this.request<{ phantom_player: PhantomPlayer }>( + `/crews/${crewId}/phantom_players/${phantomId}/assign`, + { + ...options, + method: 'POST', + body: JSON.stringify({ user_id: userId }) + } + ) + this.clearCache('/crew/members') + return response.phantom_player + } + + /** + * Confirm claim of a phantom player (by the assigned user) + */ + async confirmPhantomClaim(crewId: string, phantomId: string, options?: RequestOptions): Promise { + const response = await this.request<{ phantom_player: PhantomPlayer }>( + `/crews/${crewId}/phantom_players/${phantomId}/confirm_claim`, + { + ...options, + method: 'POST' + } + ) + this.clearCache('/crew/members') + return response.phantom_player + } +} + +export const crewAdapter = new CrewAdapter(DEFAULT_ADAPTER_CONFIG) diff --git a/src/lib/types/api/crew.ts b/src/lib/types/api/crew.ts new file mode 100644 index 00000000..7dde60a2 --- /dev/null +++ b/src/lib/types/api/crew.ts @@ -0,0 +1,122 @@ +// Crew and membership types based on Rails blueprints +// These define the crew management structure + +import type { User } from './entities' + +// Crew roles +export type CrewRole = 'member' | 'vice_captain' | 'captain' + +// Invitation status +export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired' + +// Member filter options for GET /crew/members +export type MemberFilter = 'active' | 'retired' | 'phantom' | 'all' + +// Crew from CrewBlueprint +export interface Crew { + id: string + name: string + gamertag: string | null + granblueCrewId: string | null + description: string | null + createdAt: string + // From :full view + memberCount?: number + captain?: User + viceCaptains?: User[] + // From :with_membership view (current user's membership) + currentMembership?: CrewMembership +} + +// Minimal crew for references +export interface CrewMinimal { + id: string + name: string + gamertag: string | null +} + +// CrewMembership from CrewMembershipBlueprint +export interface CrewMembership { + id: string + role: CrewRole + retired: boolean + retiredAt: string | null + joinedAt: string | null + createdAt: string + // From :with_user view + user?: User + // From :with_crew view + crew?: CrewMinimal +} + +// PhantomPlayer from PhantomPlayerBlueprint +export interface PhantomPlayer { + id: string + name: string + granblueId: string | null + notes: string | null + claimed: boolean + claimConfirmed: boolean + retired: boolean + retiredAt: string | null + joinedAt: string | null + // From :with_claimed_by view + claimedBy?: User + // From :with_scores view + totalScore?: number + scoreCount?: number +} + +// CrewInvitation from CrewInvitationBlueprint +export interface CrewInvitation { + id: string + status: InvitationStatus + expiresAt: string + createdAt: string + // From :with_crew view + crew?: CrewMinimal + // From :with_user view + user?: User + invitedBy?: User +} + +// Response type for GET /crew/members +export interface CrewMembersResponse { + members: CrewMembership[] + phantoms: PhantomPlayer[] +} + +// Input types for mutations + +export interface CreateCrewInput { + name: string + gamertag?: string + granblueCrewId?: string + description?: string +} + +export interface UpdateCrewInput { + name?: string + gamertag?: string + granblueCrewId?: string + description?: string +} + +export interface CreatePhantomPlayerInput { + name: string + granblueId?: string + notes?: string + joinedAt?: string +} + +export interface UpdatePhantomPlayerInput { + name?: string + granblueId?: string + notes?: string + joinedAt?: string +} + +export interface UpdateMembershipInput { + role?: CrewRole + joinedAt?: string +} diff --git a/src/routes/(app)/crew/members/+page.svelte b/src/routes/(app)/crew/members/+page.svelte new file mode 100644 index 00000000..e4e1d5d7 --- /dev/null +++ b/src/routes/(app)/crew/members/+page.svelte @@ -0,0 +1,903 @@ + + + + + + Crew Members | Hensei + + +
+
+ + {#snippet belowTitle()} +
+ {#each filterOptions as option} + + {/each} +
+ {/snippet} + {#snippet actions()} + {#if crewStore.isOfficer} + + + {#snippet trigger({ props })} + + {/if} +
+ {/if} + {/if} +
+ + + + + {#snippet children()} + + + +

+ {#if confirmAction === 'remove'} + Are you sure you want to remove {selectedMember?.user?.username ?? 'this member'} from the + crew? + {:else if confirmAction === 'promote'} + Promote {selectedMember?.user?.username ?? 'this member'} to Vice Captain? + {:else if confirmAction === 'demote'} + Demote {selectedMember?.user?.username ?? 'this member'} from Vice Captain to Member? + {/if} +

+
+ + + {#snippet children()} + + + {/snippet} + + {/snippet} +
+ + + + {#snippet children()} + + + + + + + + {#snippet children()} + + + {/snippet} + + {/snippet} + + + + + {#snippet children()} + + + + + + + + {#snippet children()} + + + {/snippet} + + {/snippet} + + +