PhoneCode.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. 'use client'
  2. import { useEffect, useState } from 'react'
  3. import { useTranslations } from 'next-intl'
  4. import { QRCodeSVG } from 'qrcode.react'
  5. import { Check, Copy, Loader2, X } from 'lucide-react'
  6. import { Button } from '@/components/ui/button'
  7. import { useFormatDate } from '@/lib/use-format-date'
  8. import { createFixPhoneLink } from '../Actions/createFixPhoneLink'
  9. import { listFixDraftPhotos } from '../Actions/listFixDraftPhotos'
  10. import { removeFixDraftPhoto } from '../Actions/removeFixDraftPhoto'
  11. const POLL_MS = 2500
  12. /** A v4 UUID, also where `crypto.randomUUID` is missing (plain http on a LAN). */
  13. export function uuid(): string {
  14. if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
  15. const b = crypto.getRandomValues(new Uint8Array(16))
  16. b[6] = (b[6] & 0x0f) | 0x40
  17. b[8] = (b[8] & 0x3f) | 0x80
  18. const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
  19. return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
  20. }
  21. export type PhoneLink = { url: string; expiresAt: Date }
  22. export type PhonePhoto = { id: string; url: string }
  23. /**
  24. * Photos taken with a phone for a draft on the computer: while `active`, a fresh code for the
  25. * phone (the last may have lapsed), and the photos as they land. A waveform's code asks the
  26. * phone for one picture of the scope screen; a fix's for up to four photos.
  27. */
  28. export function usePhoneDraft({
  29. active,
  30. code,
  31. draftId,
  32. purpose,
  33. }: {
  34. active: boolean
  35. code: string
  36. draftId: string
  37. purpose: 'fix' | 'waveform'
  38. }) {
  39. const [link, setLink] = useState<PhoneLink | null>(null)
  40. const [failed, setFailed] = useState(false)
  41. const [photos, setPhotos] = useState<PhonePhoto[]>([])
  42. useEffect(() => {
  43. if (!active) return
  44. let cancelled = false
  45. setLink(null)
  46. setFailed(false)
  47. createFixPhoneLink({ code, draftId, purpose })
  48. .then((result) => {
  49. if (cancelled) return
  50. if (!result.success || !result.data) {
  51. setFailed(true)
  52. return
  53. }
  54. setLink({
  55. url: `${window.location.origin}/p/${result.data.token}`,
  56. expiresAt: new Date(result.data.expiresAt),
  57. })
  58. })
  59. .catch(() => {
  60. if (!cancelled) setFailed(true)
  61. })
  62. return () => {
  63. cancelled = true
  64. }
  65. }, [active, code, draftId, purpose])
  66. useEffect(() => {
  67. if (!active) return
  68. const read = async () => {
  69. const result = await listFixDraftPhotos({ draftId }).catch(() => null)
  70. if (result?.success && result.data) setPhotos(result.data)
  71. }
  72. void read()
  73. const timer = setInterval(read, POLL_MS)
  74. return () => clearInterval(timer)
  75. }, [active, draftId])
  76. const remove = async (photoId: string) => {
  77. setPhotos((current) => current.filter((p) => p.id !== photoId))
  78. await removeFixDraftPhoto({ draftId, photoId }).catch(() => null)
  79. }
  80. return { link, failed, photos, setPhotos, remove }
  81. }
  82. /** The code to scan, how long it works, a link to copy, and what has arrived so far. */
  83. export function PhoneCodePanel({
  84. link,
  85. failed,
  86. title,
  87. body,
  88. received,
  89. }: {
  90. link: PhoneLink | null
  91. failed: boolean
  92. title: string
  93. body: string
  94. /** "2 photos from your phone", once there are some. */
  95. received?: string | null
  96. }) {
  97. const t = useTranslations('faultCodes.share.phone')
  98. const { formatTime } = useFormatDate()
  99. const [copied, setCopied] = useState(false)
  100. useEffect(() => {
  101. if (!copied) return
  102. const timer = setTimeout(() => setCopied(false), 1500)
  103. return () => clearTimeout(timer)
  104. }, [copied])
  105. return (
  106. <div className="flex flex-col items-center gap-4 rounded-lg border border-card-edge bg-muted/30 p-4 sm:flex-row sm:items-start">
  107. <div className="flex size-[164px] shrink-0 items-center justify-center rounded-lg bg-white p-2 shadow-sm">
  108. {link ? (
  109. <QRCodeSVG value={link.url} size={148} marginSize={0} />
  110. ) : failed ? (
  111. <X className="size-6 text-muted-foreground" aria-hidden />
  112. ) : (
  113. <Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
  114. )}
  115. </div>
  116. <div className="min-w-0 flex-1 space-y-2 text-center sm:text-left">
  117. <p className="text-sm font-medium">{title}</p>
  118. <p className="text-sm text-muted-foreground">{failed ? t('failed') : body}</p>
  119. {link && (
  120. <>
  121. <p className="text-xs text-muted-foreground">
  122. {t('expires', { time: formatTime(link.expiresAt) })}
  123. </p>
  124. <Button
  125. type="button"
  126. variant="ghost"
  127. size="xs"
  128. className="text-muted-foreground"
  129. onClick={() => {
  130. void navigator.clipboard?.writeText(link.url)
  131. setCopied(true)
  132. }}
  133. >
  134. {copied ? <Check /> : <Copy />}
  135. {copied ? t('copied') : t('copy')}
  136. </Button>
  137. </>
  138. )}
  139. {received && (
  140. <p className="text-xs font-medium text-primary" aria-live="polite">
  141. {received}
  142. </p>
  143. )}
  144. </div>
  145. </div>
  146. )
  147. }