hensei-web/src/lib/components/crew/ScoutUserModal.svelte
Justin Edmund c54c959522 rework ModalFooter to use action props
- onCancel callback with fixed "Nevermind" label
- optional primaryAction object (label, onclick, destructive, disabled)
- optional left snippet for custom content
2025-12-13 20:02:25 -08:00

290 lines
6.1 KiB
Svelte

<svelte:options runes={true} />
<script lang="ts">
import { userAdapter, type UserInfo } from '$lib/api/adapters/user.adapter'
import { useSendInvitation } from '$lib/api/mutations/crew.mutations'
import Dialog from '$lib/components/ui/Dialog.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
interface Props {
open: boolean
crewId: string
}
let { open = $bindable(false), crewId }: Props = $props()
const sendMutation = useSendInvitation()
// State
let usernameInput = $state('')
let isSearching = $state(false)
let searchError = $state<string | null>(null)
let foundUser = $state<UserInfo | null>(null)
let inviteSuccess = $state(false)
let inviteError = $state<string | null>(null)
// Search for user by username
async function handleSearch() {
if (!usernameInput.trim()) return
isSearching = true
searchError = null
foundUser = null
inviteSuccess = false
inviteError = null
try {
const user = await userAdapter.getInfo(usernameInput.trim())
foundUser = user
} catch (err) {
searchError = 'User not found'
} finally {
isSearching = false
}
}
// Send invitation to the found user
async function handleInvite() {
if (!foundUser?.id) return
inviteError = null
try {
await sendMutation.mutateAsync({ crewId, userId: foundUser.id })
inviteSuccess = true
// Reset after success
setTimeout(() => {
resetState()
open = false
}, 1500)
} catch (err) {
inviteError = err instanceof Error ? err.message : 'Failed to send invitation'
}
}
function resetState() {
usernameInput = ''
foundUser = null
searchError = null
inviteSuccess = false
inviteError = null
}
function handleCancel() {
resetState()
open = false
}
// Reset state when modal opens
$effect(() => {
if (open) {
resetState()
}
})
// Handle Enter key in search input
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && usernameInput.trim() && !isSearching) {
handleSearch()
}
}
</script>
<Dialog bind:open>
<ModalHeader
title="Scout Player"
description="Search for a user to invite to your crew"
/>
<ModalBody>
{#if inviteSuccess}
<div class="success-message">
<Icon name="check-circle" size={32} />
<p>Invitation sent to <strong>{foundUser?.username}</strong>!</p>
</div>
{:else}
<!-- Search input -->
<div class="search-section">
<div class="search-input-container">
<input
type="text"
bind:value={usernameInput}
placeholder="Enter username..."
class="search-input"
onkeydown={handleKeydown}
disabled={isSearching}
/>
<Button
variant="secondary"
size="small"
onclick={handleSearch}
disabled={!usernameInput.trim() || isSearching}
>
{isSearching ? 'Searching...' : 'Search'}
</Button>
</div>
{#if searchError}
<p class="error-text">{searchError}</p>
{/if}
</div>
<!-- Found user display -->
{#if foundUser}
<div class="user-result">
<div class="user-info">
{#if foundUser.avatar?.picture}
<img
src={getAvatarSrc(foundUser.avatar.picture)}
srcset={getAvatarSrcSet(foundUser.avatar.picture)}
alt={foundUser.username}
class="user-avatar"
width="48"
height="48"
/>
{:else}
<div class="user-avatar-placeholder"></div>
{/if}
<div class="user-details">
<span class="username">{foundUser.username}</span>
</div>
</div>
{#if inviteError}
<div class="invite-error">
<p>{inviteError}</p>
</div>
{/if}
</div>
{/if}
{/if}
</ModalBody>
{#if !inviteSuccess}
<ModalFooter
onCancel={handleCancel}
primaryAction={foundUser
? {
label: sendMutation.isPending ? 'Sending...' : 'Send Invitation',
onclick: handleInvite,
disabled: sendMutation.isPending
}
: undefined}
/>
{/if}
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.search-section {
margin-bottom: spacing.$unit-2x;
}
.search-input-container {
display: flex;
gap: spacing.$unit;
}
.search-input {
flex: 1;
padding: spacing.$unit spacing.$unit-2x;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: typography.$font-regular;
background: var(--input-bg, white);
color: var(--text-primary);
&:focus {
outline: none;
border-color: var(--accent-color, #3366ff);
box-shadow: 0 0 0 2px var(--focus-ring-light, rgba(51, 102, 255, 0.1));
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&::placeholder {
color: var(--text-secondary);
}
}
.error-text {
margin: spacing.$unit 0 0;
font-size: typography.$font-small;
color: colors.$error;
}
.user-result {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: spacing.$unit-2x;
}
.user-info {
display: flex;
align-items: center;
gap: spacing.$unit-2x;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
background: colors.$grey-80;
}
.user-details {
display: flex;
flex-direction: column;
gap: spacing.$unit-quarter;
}
.username {
font-weight: typography.$medium;
color: var(--text-primary);
}
.invite-error {
margin-top: spacing.$unit;
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: 4px;
p {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
}
.success-message {
display: flex;
flex-direction: column;
align-items: center;
gap: spacing.$unit;
padding: spacing.$unit-4x;
text-align: center;
color: colors.$wind-text-20;
p {
margin: 0;
}
}
</style>