~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
751 lines · 24.4 KB Raw History
<script lang="ts">
  import { goto } from '$app/navigation'
  import { createQuery, useQueryClient } from '@tanstack/svelte-query'
  import {
    applyWrites,
    atUri,
    createWrite,
    deleteWrite,
    nextTid,
    rkeyOf,
    updateWrite,
    uploadPhotoBlobs,
    type Write,
  } from '$lib/utils/records'
  import { galleryQuery } from '$lib/queries'
  import { galleryPath } from '$lib/utils'
  import type { GalleryView, PhotoView } from '$hatk/client'
  import { processPhotos, type ProcessedPhoto } from '$lib/utils/image-resize'
  import { parseTextToFacets } from '$lib/utils/rich-text'
  import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
  import Button from '$lib/components/atoms/Button.svelte'
  import Field from '$lib/components/atoms/Field.svelte'
  import Input from '$lib/components/atoms/Input.svelte'
  import Textarea from '$lib/components/atoms/Textarea.svelte'
  import RichTextarea from '$lib/components/atoms/RichTextarea.svelte'
  import LocationInput from '$lib/components/atoms/LocationInput.svelte'
  import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte'
  import OGMeta from '$lib/components/atoms/OGMeta.svelte'
  import FileDropZone from '$lib/components/atoms/FileDropZone.svelte'
  import { LoaderCircle, X, ImagePlus, RefreshCw } from 'lucide-svelte'
  import type { LocationData } from '$lib/components/atoms/LocationInput.svelte'
  import { createBskyPost } from '$lib/utils/bsky-post'

  let { data } = $props()

  const galleryQ = createQuery(() => galleryQuery(data.galleryUri))
  const gallery = $derived((galleryQ.data as GalleryView) ?? null)
  const queryClient = useQueryClient()

  // ─── Photo slot types ───────────────────────────────────────────────

  type ExistingSlot = { kind: 'existing'; view: PhotoView; replacement?: ProcessedPhoto }
  type NewSlot = { kind: 'new'; processed: ProcessedPhoto }
  type PhotoSlot = ExistingSlot | NewSlot

  // ─── Edit state ─────────────────────────────────────────────────────

  let title = $state('')
  let description = $state('')
  let location = $state<LocationData | null>(null)
  let selectedLabels = $state<string[]>([])
  let slots = $state<PhotoSlot[]>([])
  let initialized = $state(false)

  $effect(() => {
    if (gallery && !initialized) {
      title = gallery.title ?? ''
      description = (gallery as any).record?.description ?? gallery.description ?? ''
      location = gallery.location
        ? {
            // `name` is optional on community.lexicon.location.hthree, so fall
            // back the way GalleryCard does rather than seeding the input blank.
            name: gallery.location.name ?? gallery.locationDisplay ?? gallery.location.value,
            h3Index: gallery.location.value,
            address: (gallery as any).record?.address,
          }
        : null
      selectedLabels = (gallery as any).record?.labels?.values?.map((v: any) => v.val) ?? []
      slots = ((gallery.items ?? []) as PhotoView[]).map((view) => ({ kind: 'existing', view }))
      initialized = true
    }
  })

  // ─── State ──────────────────────────────────────────────────────────

  let saving = $state(false)
  let processing = $state(false)
  let posting = $state(false)
  let posted = $state(false)
  let error = $state<string | null>(null)

  function blobToDataUrl(blob: Blob): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onload = () => resolve(reader.result as string)
      reader.onerror = reject
      reader.readAsDataURL(blob)
    })
  }

  let pdsUrlCache: string | null = null

  async function resolvePdsUrl(did: string): Promise<string> {
    if (pdsUrlCache) return pdsUrlCache
    const res = await fetch(`https://plc.directory/${did}`)
    if (!res.ok) throw new Error('Failed to resolve DID document')
    const doc = await res.json()
    const svc = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
    if (!svc?.serviceEndpoint) throw new Error('PDS not found in DID document')
    // Bind before caching: `svc` is `any`, so assigning straight into the
    // `string | null` cache leaves the return un-narrowed.
    const endpoint: string = svc.serviceEndpoint
    pdsUrlCache = endpoint
    return endpoint
  }

  async function fetchPhotoDataUrl(photo: PhotoView): Promise<string> {
    const fullsize = photo.fullsize
    // Local dev: already a com.atproto.sync.getBlob URL
    if (fullsize.includes('com.atproto.sync.getBlob')) {
      const res = await fetch(fullsize)
      const blob = await res.blob()
      return blobToDataUrl(blob)
    }
    // Production: parse CID from CDN URL, fetch from PDS
    const did = photo.uri.split('/')[2]
    const blobCid = new URL(fullsize).pathname.split('/').pop()!
    const pdsUrl = await resolvePdsUrl(did)
    const res = await fetch(`${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blobCid}`)
    if (!res.ok) throw new Error(`Failed to fetch blob: ${res.status}`)
    const blob = await res.blob()
    return blobToDataUrl(blob)
  }

  async function postToBluesky() {
    if (!gallery || posting) return
    posting = true
    error = null
    try {
      const photos = (gallery.items ?? []) as PhotoView[]
      const images = await Promise.all(
        photos.slice(0, 4).map(async (photo) => ({
          dataUrl: await fetchPhotoDataUrl(photo),
          alt: photo.alt ?? '',
          width: photo.aspectRatio?.width ?? 4,
          height: photo.aspectRatio?.height ?? 3,
        }))
      )
      await createBskyPost({
        url: `${window.location.origin}${galleryPath(gallery)}`,
        title: title.trim() || undefined,
        location: gallery.location
          ? { name: gallery.location.name, address: (gallery as any).address }
          : null,
        description: description.trim() || undefined,
        images,
      })
      posted = true
    } catch (err: any) {
      error = err.message || 'Failed to post to Bluesky.'
    } finally {
      posting = false
    }
  }

  let addFileInput: HTMLInputElement | undefined = $state()
  let replaceFileInputs = $state<Record<number, HTMLInputElement>>({})

  // ─── Drag to reorder (same as create page) ──────────────────────────

  let dragIndex: number | null = $state(null)
  let dragOverIndex: number | null = $state(null)
  let dragOffsetX = $state(0)
  let dropping = $state(false)
  let longPressTimer: ReturnType<typeof setTimeout> | null = null
  let dragStartX = 0
  let stripEl: HTMLDivElement | undefined = $state(undefined)
  let slotMidpoints: number[] = []

  function snapshotGeometry() {
    if (!stripEl) return
    const thumbs = stripEl.querySelectorAll('.photo-thumb') as NodeListOf<HTMLElement>
    const prev = Array.from(thumbs).map((t) => t.style.transform)
    thumbs.forEach((t) => (t.style.transform = 'none'))
    slotMidpoints = Array.from(thumbs).map((t) => {
      const rect = t.getBoundingClientRect()
      return rect.left + rect.width / 2
    })
    thumbs.forEach((t, i) => (t.style.transform = prev[i]))
  }

  function indexFromPointerX(px: number): number {
    for (let i = 0; i < slotMidpoints.length; i++) {
      if (px < slotMidpoints[i]) return i
    }
    return slotMidpoints.length - 1
  }

  function onThumbPointerDown(e: PointerEvent, index: number) {
    if (slots.length < 2) return
    const target = e.currentTarget as HTMLElement
    dragStartX = e.clientX
    longPressTimer = setTimeout(() => {
      dragIndex = index
      dragOverIndex = index
      snapshotGeometry()
      target.setPointerCapture(e.pointerId)
    }, 200)
  }

  function onThumbPointerMove(e: PointerEvent) {
    if (dragIndex === null && longPressTimer && Math.abs(e.clientX - dragStartX) > 10) {
      clearTimeout(longPressTimer)
      longPressTimer = null
      return
    }
    if (dragIndex === null) return
    e.preventDefault()
    dragOffsetX = e.clientX - dragStartX
    const draggedMidX = slotMidpoints[dragIndex] + dragOffsetX
    dragOverIndex = indexFromPointerX(draggedMidX)
  }

  function onThumbPointerUp() {
    if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null }
    if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) {
      dropping = true
      const moved = slots[dragIndex]
      const next = slots.filter((_, i) => i !== dragIndex)
      next.splice(dragOverIndex, 0, moved)
      slots = next
      requestAnimationFrame(() => { dropping = false })
    }
    dragIndex = null
    dragOverIndex = null
    dragOffsetX = 0
  }

  function thumbShift(index: number): number {
    if (dragIndex === null || dragOverIndex === null) return 0
    if (index === dragIndex) return 0
    const slot = 80
    if (dragIndex < dragOverIndex) {
      if (index > dragIndex && index <= dragOverIndex) return -slot
    } else {
      if (index < dragIndex && index >= dragOverIndex) return slot
    }
    return 0
  }

  // ─── Photo management ───────────────────────────────────────────────

  let fileDragging = $state(false)

  function removeSlot(index: number) {
    slots = slots.filter((_, i) => i !== index)
  }

  function handleAddFiles(e: Event) {
    const input = e.target as HTMLInputElement
    const files = Array.from(input.files ?? [])
    input.value = ''
    addFiles(files)
  }

  async function addFiles(files: File[]) {
    if (!files.length) return
    try {
      processing = true
      error = null
      const processed = await processPhotos(files)
      slots = [...slots, ...processed.map((p): NewSlot => ({ kind: 'new', processed: p }))]
    } catch {
      error = 'Failed to process photos.'
    } finally {
      processing = false
    }
  }

  async function handleReplace(e: Event, index: number) {
    const input = e.target as HTMLInputElement
    const file = input.files?.[0]
    if (!input) return
    input.value = ''
    if (!file) return
    const slot = slots[index]
    if (slot.kind !== 'existing') return
    try {
      processing = true
      error = null
      const [processed] = await processPhotos([file])
      slots[index] = { ...slot, replacement: processed }
    } catch {
      error = 'Failed to process photo.'
    } finally {
      processing = false
    }
  }

  function thumbSrc(slot: PhotoSlot): string {
    if (slot.kind === 'existing') return slot.replacement?.dataUrl ?? slot.view.thumb
    return slot.processed.dataUrl
  }

  // ─── Save ────────────────────────────────────────────────────────────

  const canSave = $derived(title.trim().length > 0 && slots.length > 0 && !saving)

  async function save() {
    if (!canSave || !gallery) return
    saving = true
    error = null

    try {
      const now = new Date().toISOString()
      const galleryRkey = rkeyOf(gallery.uri)
      const did = gallery.creator?.did!
      const originalViews = ((gallery.items ?? []) as PhotoView[])

      // 1. Upload blobs for added and replaced photos. Raw bytes can't ride
      //    along in applyWrites, so these stay one request each.
      const pendingUploads = slots.flatMap((slot, index) => {
        const processed = slot.kind === 'new' ? slot.processed : slot.replacement
        return processed ? [{ index, dataUrl: processed.dataUrl }] : []
      })
      const uploaded = await uploadPhotoBlobs(pendingUploads.map((p) => p.dataUrl))
      const blobBySlot = new Map<number, unknown>()
      pendingUploads.forEach((p, i) => blobBySlot.set(p.index, uploaded[i]))

      let facets: any[] | undefined
      if (description.trim()) {
        const parsed = await parseTextToFacets(description.trim())
        if (parsed.facets.length > 0) facets = parsed.facets
      }

      // 2. Collect every record change into a single atomic applyWrites, so a
      //    save either lands whole or not at all — no more half-reordered
      //    galleries when a request drops partway through, and a replayed
      //    request can't add a second copy of a newly added photo.
      const writes: Write[] = [
        updateWrite('social.grain.gallery', galleryRkey, {
          title: title.trim(),
          ...(description.trim() ? { description: description.trim() } : {}),
          ...(facets ? { facets } : {}),
          ...(location ? { location: { name: location.name, value: location.h3Index }, ...(location.address ? { address: location.address } : {}) } : {}),
          ...(selectedLabels.length > 0 ? { labels: { $type: 'com.atproto.label.defs#selfLabels', values: selectedLabels.map((val) => ({ val })) } } : {}),
          createdAt: (gallery as any).record?.createdAt ?? now,
          updatedAt: now,
        }),
      ]

      // 3. Walk the slots in their new position order
      const finalPhotoUris: string[] = []
      for (let i = 0; i < slots.length; i++) {
        const slot = slots[i]

        if (slot.kind === 'new') {
          const photoRkey = nextTid()
          const photoUri = atUri(did, 'social.grain.photo', photoRkey)
          finalPhotoUris.push(photoUri)

          writes.push(
            createWrite('social.grain.photo', photoRkey, {
              photo: blobBySlot.get(i),
              aspectRatio: { width: slot.processed.width, height: slot.processed.height },
              alt: slot.processed.alt || '',
              createdAt: now,
            }),
          )

          if (slot.processed.exif) {
            writes.push(
              createWrite('social.grain.photo.exif', nextTid(), {
                photo: photoUri,
                ...slot.processed.exif,
                createdAt: now,
              }),
            )
          }

          writes.push(
            createWrite('social.grain.gallery.item', nextTid(), {
              gallery: gallery.uri,
              item: photoUri,
              position: i,
              createdAt: now,
            }),
          )
        } else {
          // Existing photo
          const photoRkey = rkeyOf(slot.view.uri)
          const itemUri = (slot.view.gallery as any)?.item as string | undefined
          const itemRkey = itemUri ? rkeyOf(itemUri) : undefined
          const originalPosition = (slot.view.gallery as any)?.itemPosition ?? 0

          if (slot.replacement) {
            writes.push(
              updateWrite('social.grain.photo', photoRkey, {
                photo: blobBySlot.get(i),
                aspectRatio: { width: slot.replacement.width, height: slot.replacement.height },
                alt: slot.view.alt || '',
                createdAt: (slot.view as any).record?.createdAt ?? now,
              }),
            )

            // Update EXIF if extracted
            if (slot.replacement.exif) {
              const exifRkey = slot.view.exif?.uri ? rkeyOf(slot.view.exif.uri) : undefined
              const exifValue = { photo: slot.view.uri, ...slot.replacement.exif, createdAt: now }
              writes.push(
                exifRkey
                  ? updateWrite('social.grain.photo.exif', exifRkey, exifValue)
                  : createWrite('social.grain.photo.exif', nextTid(), exifValue),
              )
            }
          }

          finalPhotoUris.push(slot.view.uri)

          // Update position on gallery.item if it changed
          if (itemRkey && originalPosition !== i) {
            writes.push(
              updateWrite('social.grain.gallery.item', itemRkey, {
                gallery: gallery.uri,
                item: slot.view.uri,
                position: i,
                createdAt: (slot.view.gallery as any)?.itemCreatedAt ?? now,
              }),
            )
          }
        }
      }

      // 4. Delete gallery.item records for removed photos
      for (const orig of originalViews) {
        if (!finalPhotoUris.includes(orig.uri)) {
          const itemUri = (orig.gallery as any)?.item as string | undefined
          if (itemUri) writes.push(deleteWrite('social.grain.gallery.item', rkeyOf(itemUri)))
        }
      }

      await applyWrites(writes)

      queryClient.invalidateQueries({ queryKey: ['gallery', data.galleryUri] })
      goto(galleryPath(gallery))
    } catch (err: any) {
      error = err.message || 'Failed to save. Please try again.'
    } finally {
      saving = false
    }
  }
</script>

<OGMeta title="Edit gallery - grain" noindex />

<FileDropZone
  enabled={!!gallery && !processing && !saving}
  {processing}
  onfiles={addFiles}
  onreject={(message) => (error = message)}
  bind:active={fileDragging}
/>

<DetailHeader label="Edit gallery" onback={() => goto(`/profile/${data.actor}/gallery/${data.rkey}`)}>
  {#snippet actions()}
    <Button disabled={!canSave} onclick={save}>
      {#if saving}<LoaderCircle size={16} class="spin" /> Saving...{:else}Save{/if}
    </Button>
  {/snippet}
</DetailHeader>

{#if error}
  <p class="error">{error}</p>
{/if}

{#if galleryQ.isLoading}
  <p class="loading">Loading...</p>
{:else if gallery}
  <!-- Photo strip -->
  <!-- svelte-ignore a11y_no_static_element_interactions -->
  <div
    class="photo-strip"
    class:dragging={dragIndex !== null}
    class:dropping
    bind:this={stripEl}
    onpointermove={onThumbPointerMove}
    onpointerup={onThumbPointerUp}
    onpointercancel={onThumbPointerUp}
  >
    {#each slots as slot, i}
      {@const shift = thumbShift(i)}
      <div
        class="photo-thumb"
        class:drag-active={dragIndex === i}
        style={dragIndex === i ? `transform: translateX(${dragOffsetX}px) scale(1.08)` : shift ? `transform: translateX(${shift}px)` : ''}
        onpointerdown={(e) => onThumbPointerDown(e, i)}
      >
        <img src={thumbSrc(slot)} alt="Photo {i + 1}" draggable="false" />
        {#if dragIndex === null}
          <button class="remove-btn" onclick={() => removeSlot(i)} title="Remove">
            <X size={12} />
          </button>
          {#if slot.kind === 'existing'}
            <button
              class="replace-btn"
              title="Replace"
              onclick={() => replaceFileInputs[i]?.click()}
            >
              <RefreshCw size={10} />
            </button>
            <input
              type="file"
              accept="image/*"
              style="display:none"
              bind:this={replaceFileInputs[i]}
              onchange={(e) => handleReplace(e, i)}
            />
          {/if}
        {/if}
      </div>
    {/each}

    <button
      class="add-btn"
      class:drag-over={fileDragging}
      onclick={() => addFileInput?.click()}
      disabled={processing}
      title="Add photos, or drag and drop"
    >
      {#if processing}<LoaderCircle size={18} class="spin" />{:else}<ImagePlus size={18} />{/if}
    </button>
  </div>

  <input
    type="file"
    accept="image/*"
    multiple
    style="display:none"
    bind:this={addFileInput}
    onchange={handleAddFiles}
  />

  <!-- Metadata form -->
  <div class="form">
    <Field label="Title" count={title.length} max={100} showCount="always">
      <Input type="text" placeholder="Add a title..." maxlength={100} bind:value={title} />
    </Field>
    <Field label="Description" count={description.length} max={1000}>
      <RichTextarea
        placeholder="Add a description. Supports @mentions, #hashtags, and links."
        maxlength={1000}
        bind:value={description}
        rows={6}
      />
    </Field>
    <Field label="Location">
      <LocationInput bind:value={location} />
    </Field>
    <Field label="Labels">
      <ContentWarningPicker bind:selected={selectedLabels} />
    </Field>
  </div>

  <!-- Alt text per photo -->
  <div class="alt-section">
    <h2 class="alt-heading">Image descriptions</h2>
    <p class="alt-hint">Describes images for blind and low-vision users.</p>
    {#each slots as slot, i}
      <div class="alt-row">
        <img class="alt-thumb" src={thumbSrc(slot)} alt="Photo {i + 1}" />
        <div class="alt-field">
          <Textarea
            placeholder="Describe this image (optional)..."
            maxlength={1000}
            value={slot.kind === 'existing' ? (slot.replacement?.alt ?? slot.view.alt ?? '') : (slot.processed.alt ?? '')}
            oninput={(e) => {
              const val = (e.target as HTMLTextAreaElement).value
              if (slot.kind === 'existing') {
                if (slot.replacement) slots[i] = { ...slot, replacement: { ...slot.replacement, alt: val } }
                else slots[i] = { ...slot, view: { ...slot.view, alt: val } }
              } else {
                slots[i] = { ...slot, processed: { ...slot.processed, alt: val } }
              }
            }}
            rows={2}
          />
        </div>
      </div>
    {/each}
  </div>

  {#if !(gallery as any).crossPost && !posted}
    <div class="bsky-section">
      <p class="bsky-hint">This gallery hasn't been shared to Bluesky yet.</p>
      <Button onclick={postToBluesky} disabled={posting}>
        {#if posting}<LoaderCircle size={16} class="spin" /> Posting...{:else}Post to Bluesky{/if}
      </Button>
    </div>
  {:else if posted}
    <div class="bsky-section">
      <p class="bsky-success">Posted to Bluesky.</p>
    </div>
  {/if}
{/if}

<style>
  .error {
    color: var(--danger);
    padding: 12px 16px;
    margin: 0;
    font-size: 14px;
    text-align: center;
  }
  .loading {
    text-align: center;
    color: var(--text-muted);
    padding: 48px 16px;
    font-size: 14px;
  }

  /* Photo strip */
  .photo-strip {
    display: flex;
    gap: 8px;
    padding: 12px 16px;
    overflow-x: auto;
    border-bottom: 1px solid var(--border);
    align-items: center;
  }
  .photo-strip.dragging { touch-action: none; overflow-x: hidden; }
  .photo-strip.dropping .photo-thumb { transition: none !important; }

  .photo-thumb {
    position: relative;
    flex-shrink: 0;
    width: 72px;
    height: 72px;
    border-radius: 6px;
    overflow: hidden;
    transition: transform 150ms ease;
    user-select: none;
    touch-action: none;
  }
  .photo-thumb.drag-active { z-index: 10; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
  .photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

  .remove-btn {
    position: absolute;
    top: 3px;
    right: 3px;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: none;
    background: rgba(0,0,0,0.7);
    color: #fff;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
  }
  .replace-btn {
    position: absolute;
    bottom: 3px;
    right: 3px;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: none;
    background: rgba(0,0,0,0.7);
    color: #fff;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
  }
  .add-btn {
    flex-shrink: 0;
    width: 72px;
    height: 72px;
    border-radius: 6px;
    border: 2px dashed var(--border);
    background: var(--bg-elevated);
    color: var(--text-muted);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: border-color 0.15s, color 0.15s;
  }
  .add-btn:hover:not(:disabled) { border-color: var(--grain); color: var(--grain); }
  .add-btn.drag-over { border-color: var(--grain); color: var(--grain); }
  .add-btn:disabled { opacity: 0.5; cursor: not-allowed; }

  /* Form */
  .form {
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 16px;
    border-bottom: 1px solid var(--border);
  }

  /* Alt section */
  .alt-section {
    padding: 16px;
  }
  .alt-heading {
    font-size: 14px;
    font-weight: 600;
    margin: 0 0 4px;
  }
  .alt-hint {
    font-size: 12px;
    color: var(--text-muted);
    margin: 0 0 12px;
  }
  .alt-row {
    display: flex;
    gap: 12px;
    align-items: flex-start;
    margin-bottom: 12px;
  }
  .alt-thumb {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 6px;
    flex-shrink: 0;
  }
  .alt-field { flex: 1; }

  /* Bluesky section */
  .bsky-section {
    padding: 16px;
    border-top: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .bsky-hint {
    font-size: 13px;
    color: var(--text-muted);
    margin: 0;
  }
  .bsky-success {
    font-size: 13px;
    color: var(--text-muted);
    margin: 0;
  }

  :global(.spin) {
    animation: spin 1s linear infinite;
  }
  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }
</style>