Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { Modal } from '@/components/ui/Modal'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { cn } from '@/lib/cn'
|
|
import { fieldControlClass } from '@/components/ui/field'
|
|
import { errorMessage } from '@/lib/api'
|
|
import type { Garden } from '@/lib/gardens'
|
|
import { useAddShare, useRemoveShare, useShares, useUpdateShareRole, type ShareRole } from '@/lib/shares'
|
|
|
|
const roleOptions = [
|
|
{ value: 'viewer', label: 'Viewer (read-only)' },
|
|
{ value: 'editor', label: 'Editor (can edit)' },
|
|
]
|
|
|
|
/**
|
|
* Owner-only dialog to manage a garden's shares: invite an existing user by
|
|
* email as viewer/editor, change a share's role, or remove it. Targets existing
|
|
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
|
|
* friendly "no account with that email".
|
|
*/
|
|
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
const shares = useShares(garden.id)
|
|
const add = useAddShare(garden.id)
|
|
const updateRole = useUpdateShareRole(garden.id)
|
|
const remove = useRemoveShare(garden.id)
|
|
|
|
const [email, setEmail] = useState('')
|
|
const [role, setRole] = useState<ShareRole>('viewer')
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function onInvite(e: FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
if (!email.trim()) {
|
|
setError('Enter an email address.')
|
|
return
|
|
}
|
|
try {
|
|
await add.mutateAsync({ email: email.trim(), role })
|
|
setEmail('')
|
|
} catch (err) {
|
|
setError(errorMessage(err, 'Could not share the garden.'))
|
|
}
|
|
}
|
|
|
|
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
|
|
|
return (
|
|
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
|
|
<div className="flex flex-col gap-4">
|
|
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
|
<TextField
|
|
label="Invite by email"
|
|
name="email"
|
|
type="email"
|
|
placeholder="[email protected]"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
<div className="flex items-end gap-2">
|
|
<Select
|
|
label="Role"
|
|
name="role"
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value as ShareRole)}
|
|
options={roleOptions}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="submit" disabled={add.isPending}>
|
|
{add.isPending ? 'Sharing…' : 'Share'}
|
|
</Button>
|
|
</div>
|
|
{error && <Alert>{error}</Alert>}
|
|
</form>
|
|
|
|
<div>
|
|
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
|
|
{shares.isPending && <p className="text-sm text-muted">Loading…</p>}
|
|
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
|
{shares.isSuccess && shares.data.length === 0 && (
|
|
<p className="text-sm text-muted">Not shared with anyone yet.</p>
|
|
)}
|
|
<ul className="flex flex-col gap-2">
|
|
{shares.data?.map((sh) => (
|
|
<li key={sh.userId} className="flex items-center gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
|
|
<p className="truncate text-xs text-muted">{sh.email}</p>
|
|
</div>
|
|
<select
|
|
value={sh.role}
|
|
onChange={(e) => {
|
|
setError(null)
|
|
updateRole.mutate(
|
|
{ userId: sh.userId, role: e.target.value as ShareRole },
|
|
{ onError: onMutationError('Could not change that role.') },
|
|
)
|
|
}}
|
|
aria-label={`Role for ${sh.displayName}`}
|
|
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
|
>
|
|
<option value="viewer">Viewer</option>
|
|
<option value="editor">Editor</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setError(null)
|
|
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
|
}}
|
|
aria-label={`Remove ${sh.displayName}`}
|
|
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|