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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
309 lines · 9.3 KB Raw History
<script lang="ts">
  import OGMeta from '$lib/components/atoms/OGMeta.svelte'
  import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
  import GalleryCard from '$lib/components/molecules/GalleryCard.svelte'
  import GalleryCardSkeleton from '$lib/components/molecules/GalleryCardSkeleton.svelte'
  import Avatar from '$lib/components/atoms/Avatar.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 SettingsGroup from '$lib/components/atoms/SettingsGroup.svelte'
  import { callXrpc, type GalleryView } from '$hatk/client'
  import { createQuery, useQueryClient } from '@tanstack/svelte-query'
  import { actorProfileQuery, privateGalleryQuery, spaceMembersQuery } from '$lib/queries'
  import { Lock } from 'lucide-svelte'
  import { page } from '$app/state'
  import { goto } from '$app/navigation'
  import { viewer } from '$lib/stores'
  import { spaceBlobViews } from '$lib/spaceBlob'

  const authority = $derived(page.params.authority ?? '')
  const skey = $derived(page.params.skey ?? '')
  const space = $derived(`at://${authority}/space/social.grain.privateGallery/${skey}`)

  const gallery = createQuery(() => privateGalleryQuery(space))
  const author = createQuery(() => actorProfileQuery(authority))
  const isAuthor = $derived($viewer?.did === authority)
  const members = createQuery(() => ({
    ...spaceMembersQuery(space),
    enabled: isAuthor && gallery.isSuccess,
  }))
  const queryClient = useQueryClient()

  /**
   * Shape the space's records into the view GalleryCard renders, so a private
   * gallery looks like every other one. Counts are absent rather than zero:
   * favorites and comments are public records, and this gallery is not, so
   * there is nothing to count.
   */
  const view = $derived.by((): GalleryView | null => {
    const data = gallery.data
    if (!data) return null
    return {
      uri: `at://${authority}/social.grain.gallery/${skey}`,
      cid: '',
      title: data.gallery?.title ?? 'Private gallery',
      description: data.gallery?.description,
      createdAt: data.gallery?.createdAt,
      creator: author.data ?? { did: authority },
      items: data.items.map((item) => ({
        uri: item.uri,
        cid: item.cid,
        ...spaceBlobViews(space, item.did, item.cid),
        alt: item.alt,
        aspectRatio: item.aspectRatio,
      })),
    } as unknown as GalleryView
  })

  // The server tells these apart (SessionExpired, SpacesUnsupported,
  // NotAuthorized) but the client cannot see which: callXrpc throws
  // `XRPC <nsid> failed: <status>` and drops the body. So the page names all
  // three causes and offers the action that fixes the likeliest.
  //
  // FOLLOW-UP: once callXrpc carries the error name through, split these.

  type Suggestion = { did: string; handle?: string; displayName?: string; avatar?: string }

  let query = $state('')
  let suggestions = $state<Suggestion[]>([])
  let adding = $state(false)
  let addError = $state<string | null>(null)
  let searchTimer: ReturnType<typeof setTimeout> | undefined

  // Typeahead runs against grain's index, which has never heard of plenty of
  // accounts worth sharing with. So the field also takes a handle typed in
  // full: the server resolves it, asking the viewer's PDS when the index
  // cannot answer.
  function search(q: string) {
    clearTimeout(searchTimer)
    if (q.trim().length < 2) {
      suggestions = []
      return
    }
    searchTimer = setTimeout(async () => {
      try {
        const result = await callXrpc('social.grain.unspecced.searchActorsTypeahead', {
          q: q.trim().replace(/^@/, ''),
          limit: 5,
        })
        suggestions = (result?.actors ?? []) as Suggestion[]
      } catch {
        suggestions = []
      }
    }, 200)
  }

  $effect(() => {
    search(query)
  })

  async function addMember(actor: string) {
    if (!actor.trim() || adding) return
    adding = true
    addError = null
    try {
      await callXrpc('social.grain.unspecced.addSpaceMember', { space, actor } as never)
      query = ''
      suggestions = []
      queryClient.invalidateQueries({ queryKey: ['spaceMembers', space] })
    } catch (err) {
      addError = err instanceof Error ? err.message : String(err)
    } finally {
      adding = false
    }
  }
</script>

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

<DetailHeader label={gallery.data?.gallery?.title ?? 'Private gallery'} />

<div class="detail-page">
  {#if gallery.isLoading}
    <GalleryCardSkeleton />
  {:else if gallery.isError}
    <p class="empty">
      This gallery couldn't be opened. It may not be shared with you, or you may need to sign in
      again, which usually fixes it.
    </p>
    <div class="action">
      <Button
        onclick={() => goto(`/oauth/login?handle=${encodeURIComponent($viewer?.handle ?? '')}`)}
      >
        Sign in again
      </Button>
    </div>
  {:else if view}
    <div class="badge-row">
      <span class="badge"><Lock size={12} /> Private</span>
    </div>

    <GalleryCard gallery={view} privateGallery />

    {#if isAuthor}
      <div class="members">
        <SettingsGroup label="Shared with">
          {#each members.data?.members ?? [] as member (member.did)}
            <div class="member-row">
              <Avatar
                did={member.did}
                src={member.avatar}
                name={member.displayName ?? member.handle}
                size={28}
              />
              <span class="member-text">
                <span class="member-name">
                  {member.displayName || (member.handle ? `@${member.handle}` : member.did)}
                </span>
                {#if member.displayName && member.handle}
                  <span class="member-handle">@{member.handle}</span>
                {/if}
              </span>
            </div>
          {/each}
          {#if members.isSuccess && (members.data?.members ?? []).length === 0}
            <div class="member-row">
              <span class="member-name muted">Nobody yet. Only you can see this.</span>
            </div>
          {/if}
        </SettingsGroup>

        <Field label="Add someone">
          <Input
            bind:value={query}
            placeholder="Search by handle, or type one in full"
            disabled={adding}
            onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && addMember(query)}
          />
        </Field>

        {#if suggestions.length > 0}
          <div class="suggestions">
            {#each suggestions as actor (actor.did)}
              <button class="suggestion" type="button" onclick={() => addMember(actor.did)}>
                <Avatar
                  did={actor.did}
                  src={actor.avatar}
                  name={actor.displayName ?? actor.handle}
                  size={28}
                />
                <span class="member-text">
                  <span class="member-name">
                    {actor.displayName || (actor.handle ? `@${actor.handle}` : actor.did)}
                  </span>
                  {#if actor.displayName && actor.handle}
                    <span class="member-handle">@{actor.handle}</span>
                  {/if}
                </span>
              </button>
            {/each}
          </div>
        {/if}

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

        {#if query.trim() && suggestions.length === 0}
          <Button onclick={() => addMember(query)} disabled={adding}>
            {adding ? 'Adding…' : `Share with ${query.trim()}`}
          </Button>
        {/if}
      </div>
    {/if}
  {/if}
</div>

<style>
  .detail-page {
    max-width: 600px;
    margin: 0 auto;
  }
  .badge-row {
    padding: 12px 16px 0;
  }
  .badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 3px 8px;
    border: 1px solid var(--border);
    border-radius: 999px;
    font-size: 12px;
    color: var(--text-muted);
  }
  .members {
    display: flex;
    flex-direction: column;
    gap: 12px;
    padding: 24px 16px 32px;
  }
  .member-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px 16px;
  }
  .member-row:not(:last-child) {
    border-bottom: 1px solid var(--border);
  }
  .member-text {
    display: flex;
    flex-direction: column;
    gap: 1px;
    text-align: left;
  }
  .member-name {
    font-size: 14px;
    color: var(--text-primary);
  }
  .member-handle {
    font-size: 12px;
    color: var(--text-muted);
  }
  .suggestions {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--border);
    border-radius: 10px;
    overflow: hidden;
  }
  .suggestion {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 16px;
    background: none;
    border: none;
    font-family: inherit;
    cursor: pointer;
  }
  .suggestion:not(:last-child) {
    border-bottom: 1px solid var(--border);
  }
  .suggestion:hover {
    background: var(--bg-hover);
  }
  .member-name.muted {
    color: var(--text-muted);
  }
  .action {
    display: flex;
    justify-content: center;
    padding: 0 16px;
  }
  .empty {
    margin: 0;
    padding: 32px 16px;
    font-size: 14px;
    line-height: 1.5;
    color: var(--text-muted);
    text-align: center;
  }
  .error {
    margin: 0;
    font-size: 13px;
    color: var(--danger);
  }
</style>