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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
495 lines · 15.6 KB Raw History
<script lang="ts">
  import type { GalleryView, PhotoView, ExifView } from '$hatk/client'
  import { isCaughtUp } from '$lib/stories'
  import Avatar from '../atoms/Avatar.svelte'
  import FavedByFollowing from './FavedByFollowing.svelte'
  import RichText from '../atoms/RichText.svelte'
  import Toast from '../atoms/Toast.svelte'
  import ExifInfo from '../atoms/ExifInfo.svelte'
  import GalleryMedia from './GalleryMedia.svelte'
  import FavoriteButton from './FavoriteButton.svelte'
  import GalleryMenu from './GalleryMenu.svelte'
  import OverflowMenu from '../atoms/OverflowMenu.svelte'
  import ProfilePopover from './ProfilePopover.svelte'
  import { relativeTime, profilePath, galleryPath } from '$lib/utils'
  import { MessageCircle, Send, ChevronLeft, ChevronRight, Heart, CircleMinus } from 'lucide-svelte'
  import { removeFromPool, withdrawFromPool} from '$lib/mutations'
  import { useQueryClient } from '@tanstack/svelte-query'
  import { share } from '$lib/utils/share'
  import { browser } from '$app/environment'
  import { requireAuth, viewer } from '$lib/stores'
  import { resolveLabels, labelDefsQuery } from '$lib/labels'
  import { createQuery } from '@tanstack/svelte-query'
  import { storyAuthorsQuery } from '$lib/queries'
  import { EyeOff, AlertTriangle, Info } from 'lucide-svelte'

  // `privateGallery` strips everything that would write a public record about
  // this gallery. A favorite, a comment or a report is an ordinary record in
  // the viewer's public repo naming the gallery's URI, so offering them for a
  // gallery that lives in a permissioned space would publish its existence to
  // the network. Delete and edit are wrong for a different reason: they act on
  // the public repo, where a private gallery has no record at all.
  let {
    gallery,
    onCommentClick,
    onStoryTap,
    privateGallery = false,
    pool = undefined,
  }: {
    gallery: GalleryView
    onCommentClick?: () => void
    onStoryTap?: (did: string) => void
    /**
     * The group whose pool this gallery is in. Favouriting and commenting
     * stay on — they simply go into that space, as the viewer's own records,
     * rather than into their public repo where they would name a private
     * gallery to everyone.
     */
    pool?: string
    privateGallery?: boolean
  } = $props()

  const isOwner = $derived($viewer?.did === gallery.creator?.did)
  const storyAuthors = createQuery(() => storyAuthorsQuery())
  const creatorStoryAuthor = $derived(
    storyAuthors.data?.find((a) => a.profile.did === gallery.creator?.did)
  )
  const creatorHasStory = $derived(!!creatorStoryAuthor)
  const creatorStoryViewed = $derived(
    !isOwner && !!creatorStoryAuthor && isCaughtUp(creatorStoryAuthor)
  )
  const queryClient = useQueryClient()
  // Signed in as the group whose pool this is — the one viewer who may take it out.
  const actingForPool = $derived(!!gallery.group && $viewer?.did === gallery.group.did)
  let removing = $state(false)
  let withdrawing = $state(false)
  async function handleWithdraw() {
    if (!gallery.group || withdrawing) return
    if (!confirm('Take this gallery out of the pool? Your photos stay yours either way.')) return
    withdrawing = true
    try {
      await withdrawFromPool(gallery.group.did, gallery.uri.split('/').pop()!, queryClient)
    } catch {
      alert('Could not take it out of the pool. Please try again.')
    } finally {
      withdrawing = false
    }
  }

  async function handleRemoveFromPool() {
    if (!gallery.group || removing) return
    if (!confirm(`Remove this gallery from ${gallery.group.displayName ?? gallery.group.handle}'s pool?`)) return
    removing = true
    try {
      await removeFromPool(gallery.group.item, queryClient)
    } finally {
      removing = false
    }
  }
  let doFavorite: (() => void) | undefined = $state(undefined)

  const displayName = $derived(
    gallery.creator?.displayName || (gallery.creator?.handle ? `@${gallery.creator.handle}` : gallery.creator?.did?.slice(0, 18) + '\u2026')
  )
  const handle = $derived(gallery.creator?.handle ? `@${gallery.creator.handle}` : '')
  const avatarSrc = $derived(gallery.creator?.avatar ?? null)
  const timeStr = $derived(relativeTime(gallery.createdAt || ''))
  const photos = $derived((gallery.items ?? []) as PhotoView[])
  const favCount = $derived(gallery.favCount ?? 0)
  const commentCount = $derived(gallery.commentCount ?? 0)
  // A gallery in a group's pool keeps the author's address and adds which
  // pool to read it from — it is not in the public repo, so the page cannot
  // find it otherwise.
  // `galleryPath` prefers the author's handle, which is right for a public
  // gallery and wrong for a pooled one: getPoolGallery matches the author by
  // DID against what the space reports, and a handle never equals a DID. So a
  // pooled link addresses the author the way the pool read will.
  const galleryHref = $derived(
    privateGallery && gallery.group
      ? `/profile/${gallery.creator?.did}/gallery/${gallery.uri.split('/').pop()}` +
        `?group=${encodeURIComponent(gallery.group.did)}`
      : galleryPath(gallery),
  )
  const favedByFollowing = $derived(gallery.favedByFollowing ?? [])


  let showToast = $state(false)

  async function handleShare() {
    const url = `${window.location.origin}${galleryHref}`
    const result = await share(url)
    if (result.success && result.method === 'clipboard') {
      showToast = true
    }
  }


  const labelDefs = createQuery(() => labelDefsQuery())
  const labelResult = $derived(resolveLabels(gallery.labels, labelDefs.data ?? []))
  let revealed = $state(false)

  let currentIndex = $state(0)
  let descriptionExpanded = $state(false)
  let descriptionClamped = $state(false)
  let descriptionEl: HTMLParagraphElement | undefined = $state(undefined)

  $effect(() => {
    if (descriptionEl && !descriptionExpanded) {
      descriptionClamped = descriptionEl.scrollHeight > descriptionEl.clientHeight + 1
    }
  })
  const currentExif = $derived(photos[currentIndex]?.exif as ExifView | undefined)




</script>

{#if (labelResult.action === 'hide' || labelResult.action === 'warn-content') && !revealed}
  <article class="gallery-card gallery-hidden">
    <div class="media-warning-bar">
      <div class="media-warning-left">
        <Info size={16} />
        <span>{labelResult.name}</span>
      </div>
      <button class="media-warning-show" onclick={() => (revealed = true)}>Show</button>
    </div>
  </article>
{:else}
<article class="gallery-card" class:has-label-badge={labelResult.action === 'badge'}>
  <div>
  <header class="card-header">
    <ProfilePopover did={gallery.creator?.did ?? ''}>
      <a href={profilePath(gallery.creator)} class="author-chip">
        <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} name={displayName} size={40} hasStory={creatorHasStory} storyViewed={creatorStoryViewed} onclick={creatorHasStory && onStoryTap ? () => { onStoryTap!(gallery.creator!.did) } : undefined} />
        <div class="author-info">
          <span class="author-name-row">
            <span class="author-handle">{displayName}</span>
            {#if handle}<span class="author-subtext">{handle}</span>{/if}
            <span class="header-time">· {timeStr}</span>
          </span>
          {#if gallery.group || gallery.location}
            <span class="sub-row">
              {#if gallery.group}
                <!-- svelte-ignore node_invalid_placement_ssr -->
                <a class="group-link" href="/group/{gallery.group.did}" onclick={(e) => e.stopPropagation()}>
                  in <b>{gallery.group.displayName ?? gallery.group.handle}</b>
                </a>
              {/if}
              {#if gallery.group && gallery.location}<span class="sub-sep">·</span>{/if}
              {#if gallery.location}
                <!-- svelte-ignore node_invalid_placement_ssr -->
                <a class="location-link" href="/location/{encodeURIComponent(gallery.location.value)}?name={encodeURIComponent(gallery.locationDisplay ?? gallery.location.name ?? gallery.location.value)}" onclick={(e) => e.stopPropagation()}>
                  {gallery.locationDisplay ?? gallery.location.name ?? gallery.location.value}
                </a>
              {/if}
            </span>
          {/if}
        </div>
      </a>
    </ProfilePopover>
    {#if !privateGallery}
      <GalleryMenu {gallery} extra={actingForPool ? removeFromPoolItem : undefined} />
    {:else if isOwner && gallery.group}
      <!-- A pooled gallery is the author's record in the group's space, so
           the only action the card offers on one is taking it back out. The
           rest of GalleryMenu reaches for a public-repo record that is not
           there. -->
      <OverflowMenu horizontal>{@render withdrawItem()}</OverflowMenu>
    {/if}
  </header>

  <GalleryMedia
    {photos}
    title={gallery.title}
    bind:currentIndex
    obscured={labelResult.action === 'warn-media' && !revealed}
    warnLabel={labelResult.name}
    onShow={() => (revealed = true)}
    onDoubleTap={() => doFavorite?.()}
  />

  <div class="engagement">
    {#if !privateGallery || pool}
    <FavoriteButton
      galleryUri={gallery.uri}
      viewerFav={gallery.viewer?.fav ?? null}
      {favCount}
      {pool}
      countHref={pool ? undefined : `${galleryHref}/favorited-by`}
      bind:favorite={doFavorite}
    />
    <button class="stat" type="button" onclick={() => requireAuth() && onCommentClick?.()}>
      <MessageCircle size={20} />
      {#if commentCount > 0}<span class="stat-count">{commentCount}</span>{/if}
    </button>
    {/if}
    <button class="stat" type="button" onclick={handleShare} aria-label="Share">
      <Send size={20} />
    </button>
  </div>

  {#if favedByFollowing.length > 0 && !privateGallery}
    <div class="faved-by-row">
      <FavedByFollowing people={favedByFollowing} href="{galleryHref}/favorited-by" />
    </div>
  {/if}

  <Toast message="Link copied" bind:visible={showToast} />

  {#if currentExif}
    <ExifInfo exif={currentExif} />
  {/if}

  <div class="card-content">
    <a href={galleryHref} class="title-link">
      <p class="title">{gallery.title}</p>
    </a>
    {#if gallery.description}
      <p class="description" class:expanded={descriptionExpanded} bind:this={descriptionEl}><RichText text={gallery.description} /></p>
      {#if descriptionClamped && !descriptionExpanded}
        <button class="more-btn" type="button" onclick={() => (descriptionExpanded = true)}>more</button>
      {/if}
    {/if}
    {#if labelResult.action === 'badge'}
      <span class="label-badge"><AlertTriangle size={12} /> {labelResult.name}</span>
    {/if}
  </div>
  </div>
</article>
{/if}

{#snippet withdrawItem()}
  <button class="menu-item" type="button" onclick={handleWithdraw} disabled={withdrawing}>
    <CircleMinus size={15} />
    Take out of the pool
  </button>
{/snippet}

{#snippet removeFromPoolItem()}
  <button class="menu-item" type="button" onclick={handleRemoveFromPool} disabled={removing}>
    <CircleMinus size={15} />
    Remove from pool
  </button>
{/snippet}

<style>
.gallery-card {
    margin-bottom: 32px;
    /* Skip layout/paint for offscreen cards. `auto` remembers the last measured
       size, so scroll position stays stable once a card has been rendered. */
    content-visibility: auto;
    contain-intrinsic-size: auto 640px;
  }
/* Header */
  .card-header { padding: 12px 0; display: flex; align-items: center; }
.author-chip {
    display: flex;
    align-items: center;
    gap: 10px;
    text-decoration: none;
    color: inherit;
    min-width: 0;
    overflow: hidden;
  }
.author-info {
    display: flex;
    flex-direction: column;
    min-width: 0;
    overflow: hidden;
  }
.author-name-row {
    display: flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
  }
.author-handle {
    font-weight: 600;
    font-size: 15px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
.author-subtext {
    font-size: 13px;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    flex-shrink: 1;
  }
.header-time {
    font-size: 13px;
    color: var(--text-muted);
    white-space: nowrap;
    flex-shrink: 0;
  }
  .sub-row {
    display: flex;
    align-items: center;
    gap: 5px;
    min-width: 0;
    font-size: 12px;
    color: var(--text-muted);
  }
  .sub-sep { flex-shrink: 0; }
  .group-link {
    color: var(--text-muted);
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .group-link b { color: var(--grain); font-weight: 600; }
  .group-link:hover b { text-decoration: underline; }

  .card-header :global(.overflow-menu) {
    margin-left: auto;
  }
/* Engagement */
  .engagement {
    display: flex;
    align-items: center;
    gap: 16px;
    padding: 10px 0;
  }
.stat {
    display: flex;
    align-items: center;
    gap: 6px;
    background: none;
    border: none;
    color: var(--text-primary);
    cursor: pointer;
    padding: 0;
    font-family: inherit;
    font-size: 13px;
    transition: opacity 0.15s;
  }
.stat:hover { opacity: 0.7; }
.stat-count { color: var(--text-secondary); }
/* "Favorited by people you follow" facepile */
  .faved-by-row {
    padding: 0 0 10px;
  }
/* Content */
  .card-content { padding: 0 0 14px; }
.title-link {
    text-decoration: none;
    color: inherit;
  }
.title-link:hover .title {
    text-decoration: underline;
  }
.title {
    font-weight: 600;
    font-size: 16px;
    margin: 0 0 4px;
  }
.description {
    font-size: 14px;
    color: var(--text-secondary);
    margin: 0 0 4px;
    white-space: pre-wrap;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
.description.expanded {
    display: block;
    -webkit-line-clamp: unset;
    line-clamp: unset;
    overflow: visible;
  }
.more-btn {
    background: none;
    border: none;
    padding: 0;
    margin: 0 0 4px;
    font-size: 13px;
    font-family: inherit;
    color: var(--text-muted);
    cursor: pointer;
  }
.more-btn:hover {
    color: var(--text-secondary);
  }
.location-link {
    font-size: 13px;
    color: var(--text-muted);
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
.location-link:hover {
    color: var(--grain);
  }
/* Label moderation states */
  .gallery-hidden {
    padding: 12px;
  }
.gallery-hidden .media-warning-bar {
    position: relative;
    top: auto;
    left: auto;
    right: auto;
    transform: none;
  }
.media-warning-bar {
    position: absolute;
    top: 50%;
    left: 12px;
    right: 12px;
    transform: translateY(-50%);
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px 14px;
    background: var(--bg-secondary);
    border-radius: 8px;
    border: 1px solid var(--border);
  }
.media-warning-left {
    display: flex;
    align-items: center;
    gap: 8px;
    color: var(--text-secondary);
    font-size: 14px;
    font-weight: 500;
  }
.media-warning-show {
    background: none;
    border: none;
    color: var(--grain);
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    font-family: var(--font-body);
    padding: 0;
  }
.media-warning-show:hover {
    opacity: 0.8;
  }
.label-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    color: var(--text-muted);
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 2px 8px;
    margin-top: 4px;
  }
/* On a phone the container edge is the screen edge, so text gets a gutter.
     The carousel breaks back out of it and stays edge to edge. */
  @media (max-width: 600px) {
    .gallery-card { padding-left: 12px; padding-right: 12px; }
  }
</style>