All files / web/src/components/create/abacus DesignLinkChip.tsx

97.72% Statements 129/132
96% Branches 24/25
100% Functions 2/2
97.72% Lines 129/132

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 1331x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 5x 5x 5x 5x 2x 2x 2x 3x 3x 3x 3x 3x 3x 3x 5x 5x 16x 16x 1x 15x 1x 14x 16x 16x 16x 16x 16x 16x 16x 16x 11x 5x 2x 1x 1x 3x 1x 2x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 1x 15x 16x 16x 16x 16x 16x 2x 2x 2x 2x 2x 2x 16x 16x 1x 1x 1x 16x 16x       16x 16x 16x 16x 16x 16x 16x  
'use client'
 
// DesignLinkChip (Gitea #25) — the studio-native front door to a persisted
// design: one click saves the current design (idempotent — the server dedups
// identical content per owner, and the controller short-circuits an unchanged
// re-copy with no POST at all) and copies its ?design= deep link. The page also
// rewrites the address bar to the same link, so what's copied === what's shown.
// Deliberately NOT called "save" — that verb belongs to the player-identity
// save ("make this {player}'s abacus"), which is about a PERSON. Who may open
// the link is a separate axis, rendered under the chip by DesignShareToggle
// (#24) as a property of the link rather than a third competing verb.
//
// Feedback is an in-place label swap (useClipboard's copied window), never a
// floating toast — HUD overlays are reserved for the scene.
 
import { useState } from 'react'
import { button, STUDIO } from '@/components/studio/theme'
import { useClipboard } from '@/hooks/useClipboard'
import { useAbacusStudio } from './AbacusStudioContext'
import { DesignShareToggle } from './DesignShareToggle'
import { studioHref } from './studio-url'
 
export interface DesignLinkChipProps {
  /** ?player= value the copied link should keep selecting (pre-fallback) */
  selectedPlayerId: string | null
  /** the save minted/re-used an id — the page mirrors it into the URL */
  onDesignSaved: (designId: string) => void
}
 
export function DesignLinkChip({ selectedPlayerId, onDesignSaved }: DesignLinkChipProps) {
  const {
    saveDesignSnapshot,
    designLinkPending,
    savedDesignId,
    designShared,
    designAccess,
    designLinkStale,
  } = useAbacusStudio()
  const { copied, copy } = useClipboard()
  // saveFailed and clipboardRefused are mutually exclusive outcomes of the last
  // click; both clear on the next attempt.
  const [saveFailed, setSaveFailed] = useState(false)
  const [clipboardRefused, setClipboardRefused] = useState(false)
 
  const onCopyLink = async () => {
    setSaveFailed(false)
    setClipboardRefused(false)
    const id = await saveDesignSnapshot()
    if (!id) {
      setSaveFailed(true)
      return
    }
    // URL first: the address bar is the honest fallback if the clipboard says no.
    onDesignSaved(id)
    const href = `${window.location.origin}${studioHref('/create/abacus', {
      playerId: selectedPlayerId,
      designId: id,
    })}`
    const ok = await copy(href)
    if (!ok) setClipboardRefused(true)
  }
 
  const label = designLinkPending
    ? 'Saving design…'
    : copied
      ? '✓ Link copied'
      : '🔗 Copy design link'
 
  // Four states, because the honest sentence differs in each. Only `manageable`
  // licenses a claim about who can open it; `not-yours` is the stranger reading
  // a shared design (necessarily shared — an unshared one would not have
  // loaded), where the useful thing to say is how to make a copy of your own;
  // `unknown` (access failed, or a partial context) claims nothing at all.
  const access = designAccess ?? 'unknown'
  const title = !savedDesignId
    ? 'Saves this exact design and copies a link that reopens it. Only you can open it until you share it.'
    : access === 'manageable'
      ? designShared
        ? 'This design is saved and shared — anyone with the link can open it.'
        : 'This design is saved — copy its link. Only you can open it.'
      : access === 'not-yours'
        ? "Someone else's shared design — this link opens for anyone who has it. Change anything to start a copy of your own."
        : 'This design is saved — copy its link.'
 
  return (
    <div
      data-element="abacus-design-link"
      style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
    >
      <button
        type="button"
        data-action="copy-design-link"
        onClick={onCopyLink}
        disabled={designLinkPending}
        title={title}
        style={{
          ...button('chip', { disabled: designLinkPending }),
          alignSelf: 'flex-start',
          cursor: designLinkPending ? 'default' : 'pointer',
          // the copied window is the only feedback this control gets, so it
          // borrows the ok tone rather than a colour of its own
          ...(copied
            ? { color: STUDIO.color.tone.ok.text, border: `1px solid ${STUDIO.color.tone.ok.bar}` }
            : null),
        }}
      >
        {label}
      </button>
      {saveFailed && (
        <span
          data-element="abacus-design-link-error"
          style={{ ...STUDIO.type.note, color: STUDIO.color.dangerInline }}
        >
          Couldn&apos;t save the design — try again.
        </span>
      )}
      {clipboardRefused && (
        <span data-element="abacus-design-link-note" style={STUDIO.type.note}>
          Design saved — its link is in the address bar.
        </span>
      )}
      {designLinkStale && (
        <span data-element="abacus-design-link-stale" style={STUDIO.type.note}>
          Edited — copy a new link to share this version.
        </span>
      )}
      {/* who may open the saved link (#24) — a property of the link, not a
          third save verb. Renders only for an owner with something saved. */}
      <DesignShareToggle />
    </div>
  )
}