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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
228 lines · 6.7 KB Raw History
<script lang="ts">
  import { galleryPath } from '$lib/utils'
  import type { GalleryView, PhotoView } from '$hatk/client'
  import Skeleton from '../atoms/Skeleton.svelte'
  import Spinner from '../atoms/Spinner.svelte'
  import { resolveLabels, labelDefsQuery } from '$lib/labels'
  import { createQuery } from '@tanstack/svelte-query'
  import { Info } from 'lucide-svelte'
  import SelectCheck from '../atoms/SelectCheck.svelte'
  import { infiniteScroll } from '$lib/actions/infinite-scroll'
  import Avatar from '../atoms/Avatar.svelte'

  const labelDefs = createQuery(() => labelDefsQuery())

  let {
    items,
    loading = false,
    emptyText = 'No galleries yet.',
    hasMore = false,
    loadingMore = false,
    onLoadMore,
    selectMode = false,
    selectedUris = new Set<string>(),
    onToggle,
    showAuthor = false,
    hrefFor,
  }: {
    items: GalleryView[]
    loading?: boolean
    emptyText?: string
    hasMore?: boolean
    loadingMore?: boolean
    onLoadMore?: () => void
    selectMode?: boolean
    selectedUris?: Set<string>
    onToggle?: (uri: string) => void
    /** Tiles carry the author's face — for grids that are many people's work, like a group pool. */
    showAuthor?: boolean
    /** Where a tile goes, when it is not the author's public gallery page — a pool's is not. */
    hrefFor?: (gallery: GalleryView) => string
  } = $props()

  function thumb(gallery: GalleryView): string | undefined {
    const photos = (gallery.items ?? []) as PhotoView[]
    return photos[0]?.thumb
  }

  function photoCount(gallery: GalleryView): number {
    return ((gallery.items ?? []) as PhotoView[]).length
  }

  function rkey(uri: string): string {
    return uri.split('/').pop() ?? ''
  }
</script>

{#if loading}
  <div class="grid">
    {#each {length: 9} as _}
      <div class="cell"><Skeleton width="100%" height="100%" radius="0" /></div>
    {/each}
  </div>
{:else if items.length === 0}
  <div class="empty-state">{emptyText}</div>
{:else}
  <div class="grid">
    {#each items as gallery, i (`${gallery.uri}:${i}`)}
      {@const lr = resolveLabels(gallery.labels, labelDefs.data ?? [])}
      <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
      <a
        class="cell"
        href={selectMode ? undefined : (hrefFor?.(gallery) ?? galleryPath(gallery))}
        role={selectMode ? 'button' : undefined}
        tabindex={selectMode ? 0 : undefined}
        onclick={selectMode ? (e) => { e.preventDefault(); onToggle?.(gallery.uri) } : undefined}
      >
        {#if lr.action === 'warn-media' || lr.action === 'warn-content' || lr.action === 'hide'}
          <div class="label-cover">
            <Info size={14} />
            <span>{lr.name}</span>
          </div>
        {:else}
          {#if thumb(gallery)}
            <img
              src={thumb(gallery)}
              alt={gallery.title ?? ''}
              decoding="async"
              loading="lazy"
              onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')}
            />
          {/if}
          {#if photoCount(gallery) > 1}
            <div class="multi-icon">
              <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
                <path transform="matrix(0.807,0,0,0.807,-0.41,18.81)" d="M18.43-20.72L6.67-20.72C4.22-20.72 2.99-19.50 2.99-17.09L2.99-5.24C2.99-2.82 4.22-1.61 6.67-1.61L18.43-1.61C20.88-1.61 22.10-2.81 22.10-5.24L22.10-17.09C22.10-19.51 20.88-20.72 18.43-20.72ZM27.79-11.67C27.79-14.09 26.57-15.29 24.12-15.29L23.71-15.29L23.71-5.24C23.71-1.92 21.76 0 18.43 0L8.67 0L8.67 0.19C8.67 2.60 9.90 3.82 12.35 3.82L24.12 3.82C26.57 3.82 27.79 2.61 27.79 0.19Z" />
              </svg>
            </div>
          {/if}
          <div class="overlay">
            <span class="overlay-title">{gallery.title}</span>
          </div>
          {#if showAuthor && gallery.creator}
            <div class="who">
              <Avatar did={gallery.creator.did} src={gallery.creator.avatar ?? null} name={gallery.creator.displayName ?? gallery.creator.handle} size={20} />
              <span>{gallery.creator.displayName || gallery.creator.handle}</span>
            </div>
          {/if}
        {/if}
        {#if selectMode}
          <div class="select-check">
            <SelectCheck checked={selectedUris.has(gallery.uri)} onMedia />
          </div>
        {/if}
      </a>
    {/each}
  </div>
  {#if hasMore}
    <div use:infiniteScroll={() => { if (!loadingMore) onLoadMore?.() }} class="sentinel">
      {#if loadingMore}<Spinner />{/if}
    </div>
  {/if}
{/if}

<style>
  .who {
    position: absolute;
    left: 8px;
    bottom: 8px;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 12px;
    color: #fff;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
    pointer-events: none;
    transition: opacity 0.15s;
  }
  /* The author and the title want the same corner, so they take turns: at rest
     the tile says whose it is, and under the cursor it says which it is. */
  .cell:hover .who,
  .cell:focus-visible .who {
    opacity: 0;
  }
  .who :global(img), .who :global(.avatar) { border: 1.5px solid #fff; }
  .grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 2px;
  }
  .cell {
    display: block;
    aspect-ratio: 3 / 4;
    background: var(--bg-image);
    position: relative;
    overflow: hidden;
  }
  .cell img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    transition: opacity 0.2s ease;
  }
  .cell img:global(.loaded) {
    opacity: 1;
  }
  .multi-icon {
    position: absolute;
    top: 6px;
    right: 6px;
    color: #fff;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
    z-index: 1;
  }
  .overlay {
    position: absolute;
    inset: 0;
    background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%);
    display: flex;
    align-items: flex-end;
    padding: 8px;
    opacity: 0;
    transition: opacity 0.15s;
  }
  .cell:hover .overlay,
  .cell:focus-visible .overlay {
    opacity: 1;
  }
  .overlay-title {
    font-size: 12px;
    font-weight: 600;
    color: #fff;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    width: 100%;
  }
  .label-cover {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    width: 100%;
    height: 100%;
    color: var(--text-secondary);
    font-size: 11px;
    font-weight: 500;
  }
  .select-check {
    position: absolute;
    top: 6px;
    left: 6px;
    z-index: 2;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
  }
  .sentinel {
    display: flex;
    justify-content: center;
    padding: 20px;
  }
  .empty-state {
    padding: 48px;
    text-align: center;
    color: var(--text-secondary);
  }
</style>