All files / Bandstand/src/components AvatarEditor.tsx

94.59% Statements 105/111
71.42% Branches 15/21
55.55% Functions 5/9
94.59% Lines 105/111

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143    1x 1x 1x 1x 1x             7x 7x 7x 7x 7x 7x 7x             7x 7x 7x 7x 7x 7x     7x   7x 2x 2x 2x 2x 2x   7x 1x 1x 1x 1x 1x 1x 1x       1x 1x 1x 1x 1x 1x 1x 1x   7x 1x 1x 1x 1x 1x   7x 7x 7x 7x 7x 7x 7x 7x 7x 1x 1x 1x 7x   7x   7x 7x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x     6x 6x 6x 6x 6x 6x 6x 6x   6x 6x 6x 6x 6x 6x 6x 6x   7x 7x 7x 2x 2x 2x 2x 2x   2x   7x         7x 7x 7x   7x  
'use client'
 
import {useRouter} from 'next/navigation'
import {useSession} from 'next-auth/react'
import {useRef, useState} from 'react'
import Avatar from '@/components/Avatar'
import {squareJpeg} from '@/lib/resize-image'
 
/**
 * Pick a photo for a member: tap the picture (or the button) to choose one.
 * Full size on the Account page; `compact` is just the tappable picture, for
 * an admin setting someone else's on the Band members list.
 */
export default function AvatarEditor({
  userId,
  name,
  src,
  self = false,
  compact = false,
}: {
  userId: string
  name: string
  src: string | null
  /** Their own photo: the header picture updates too */
  self?: boolean
  compact?: boolean
}) {
  const router = useRouter()
  const {update} = useSession()
  const input = useRef<HTMLInputElement>(null)
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)
 
  // Compact has no room for a message line
  const fail = (msg: string) => (compact ? window.alert(msg) : setError(msg))
 
  async function done(r: Response) {
    setBusy(false)
    if (!r.ok) return fail('Could not save the photo. Try another one.')
    if (self) await update?.()
    router.refresh()
  }
 
  async function choose(file: File | undefined) {
    if (!file) return
    setBusy(true)
    setError(null)
    let body: Blob
    try {
      body = await squareJpeg(file)
    } catch {
      setBusy(false)
      return fail('That file isn’t a photo this browser can read.')
    }
    await done(
      await fetch(`/api/avatars/${userId}`, {
        method: 'PUT',
        headers: {'Content-Type': 'image/jpeg'},
        body,
      }),
    )
  }
 
  async function remove() {
    if (!window.confirm('Remove this photo?')) return
    setBusy(true)
    setError(null)
    await done(await fetch(`/api/avatars/${userId}`, {method: 'DELETE'}))
  }
 
  const picker = (
    <input
      ref={input}
      type="file"
      accept="image/*"
      className="sr-only"
      tabIndex={-1}
      aria-hidden="true"
      onChange={(e) => {
        choose(e.target.files?.[0])
        e.target.value = ''
      }}
    />
  )
  const label = src ? `Change photo for ${name}` : `Add a photo for ${name}`
 
  if (compact)
    return (
      <span className="relative shrink-0">
        <button
          type="button"
          onClick={() => input.current?.click()}
          disabled={busy}
          aria-label={label}
          title={label}
          className="block rounded-full disabled:opacity-50"
        >
          <Avatar name={name} src={src} size={40} />
        </button>
        {picker}
      </span>
    )
 
  return (
    <div className="flex items-center gap-4">
      <button
        type="button"
        onClick={() => input.current?.click()}
        disabled={busy}
        aria-label={label}
        className="rounded-full disabled:opacity-50"
      >
        <Avatar name={name} src={src} size={88} />
      </button>
      <div className="flex flex-col items-start gap-1">
        <button
          type="button"
          onClick={() => input.current?.click()}
          disabled={busy}
          className="min-h-11 rounded-lg border border-line-2 px-4 font-semibold disabled:opacity-50"
        >
          {busy ? 'Saving…' : src ? 'Change photo' : 'Add a photo'}
        </button>
        {src && !busy && (
          <button
            type="button"
            onClick={remove}
            className="min-h-9 text-sm text-muted underline"
          >
            Remove
          </button>
        )}
        {error && (
          <p role="alert" className="text-sm text-bad">
            {error}
          </p>
        )}
      </div>
      {picker}
    </div>
  )
}