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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
642 lines · 21.9 KB Raw History
<script lang="ts">
  import GalleryGrid from '$lib/components/organisms/GalleryGrid.svelte'
  import Avatar from '$lib/components/atoms/Avatar.svelte'
  import AvatarLightbox from '$lib/components/atoms/AvatarLightbox.svelte'
  import Facepile from '$lib/components/atoms/Facepile.svelte'
  import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
  import OGMeta from '$lib/components/atoms/OGMeta.svelte'
  import Skeleton from '$lib/components/atoms/Skeleton.svelte'
  import FollowButton from '$lib/components/molecules/FollowButton.svelte'
  import OverflowMenu from '$lib/components/atoms/OverflowMenu.svelte'
  import RichText from '$lib/components/atoms/RichText.svelte'
  import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX, Share, Trash2, X, LoaderCircle, UsersRound } from 'lucide-svelte'
  import { callXrpc } from '$hatk/client'
  import { share } from '$lib/utils/share'
  import { profilePath } from '$lib/utils'
  import { snippet, labelsBlockIndexing, profileJsonLd, atTags } from '$lib/seo'
  import Toast from '$lib/components/atoms/Toast.svelte'
  import { createQuery, createInfiniteQuery, useQueryClient } from '@tanstack/svelte-query'
  import { actorProfileQuery, actorFeedQuery, actorFavoritesInfiniteQuery, knownFollowersQuery, storiesQuery, groupQuery } from '$lib/queries'
  import { viewer as viewerStore, requireAuth } from '$lib/stores'
  import { blockActor, unblockActor, muteActor, unmuteActor } from '$lib/mutations'
  import StoryViewer from '$lib/components/organisms/StoryViewer.svelte'
  import StoryArchive from '$lib/components/molecules/StoryArchive.svelte'
  import LoginWall from '$lib/components/organisms/LoginWall.svelte'

  // Gate signed-out visitors behind a sign-in prompt after they scroll a way
  // into a profile. Off for now.
  const LOGIN_WALL = false
  import { page } from '$app/state'
  import { goto } from '$app/navigation'

  const validTabs = ['grid', 'favorites', 'stories'] as const
  type ViewMode = (typeof validTabs)[number]
  function parseTab(v: string | null): ViewMode {
    return validTabs.includes(v as ViewMode) ? (v as ViewMode) : 'grid'
  }

  let { data } = $props()
  let lightboxSrc: string | null = $state(null)
  const viewMode: ViewMode = $derived.by(() => {
    page.url.href
    return parseTab(page.url.searchParams.get('tab'))
  })
  let followersOffset = $state(0)
  let showStoryViewer = $state(false)
  const did = $derived(data.did)
  $effect(() => { void did; void profile.data; followersOffset = 0 })

  function setTab(tab: ViewMode) {
    exitSelectMode()
    const url = new URL(page.url)
    if (tab === 'grid') {
      url.searchParams.delete('tab')
    } else {
      url.searchParams.set('tab', tab)
    }
    goto(url, { replaceState: true, keepFocus: true, noScroll: true })
  }
  const viewerDid = $derived($viewerStore?.did)
  const isOwnProfile = $derived(viewerDid === did)

  const profile = createQuery(() => actorProfileQuery(did, viewerDid))
  // Links out of this page use the handle once the profile has one; until
  // then the DID is what the route already accepts.
  const profileHref = $derived(profilePath(profile.data ?? { did }))
  const feed = createInfiniteQuery(() => actorFeedQuery(did))
  const feedItems = $derived(feed.data?.pages.flatMap((p) => p.items ?? []) ?? [])
  const favorites = createInfiniteQuery(() => ({
    ...actorFavoritesInfiniteQuery(did),
    enabled: isOwnProfile && viewMode === 'favorites',
  }))
  const favoriteItems = $derived(favorites.data?.pages.flatMap((p) => p.items ?? []) ?? [])
  const stories = createQuery(() => storiesQuery(did))
  const hasStory = $derived((stories.data?.length ?? 0) > 0)
  const storyViewed = $derived(
    !isOwnProfile && hasStory && (stories.data?.every((s) => s.viewer?.viewed) ?? false)
  )
  // A group shows up as an ordinary author; this is the one pointer to its group face.
  const group = createQuery(() => ({ ...groupQuery(did), retry: false }))
  const knownFollowers = createQuery(() => ({
    ...knownFollowersQuery(did, viewerDid ?? ''),
    enabled: !!viewerDid && viewerDid !== did,
  }))

  const queryClient = useQueryClient()

  async function handleBlock() {
    if (!requireAuth()) return
    const p = profile.data as any
    if (p?.viewer?.blocking) {
      await unblockActor(did, p.viewer.blocking, queryClient)
    } else {
      await blockActor(did, queryClient)
    }
  }

  async function handleMute() {
    if (!requireAuth()) return
    const p = profile.data as any
    if (p?.viewer?.muted) {
      await unmuteActor(did, queryClient)
    } else {
      await muteActor(did, queryClient)
    }
  }

  let showToast = $state(false)
  let toastMessage = $state('Link copied')

  // Bulk select & delete
  let selectMode = $state(false)
  let selectedUris = $state(new Set<string>())
  let deleting = $state(false)
  let deleteProgress = $state({ current: 0, total: 0 })

  function toggleSelect(uri: string) {
    const next = new Set(selectedUris)
    if (next.has(uri)) next.delete(uri)
    else next.add(uri)
    selectedUris = next
  }

  function exitSelectMode() {
    selectMode = false
    selectedUris = new Set()
  }

  async function bulkDelete() {
    if (selectedUris.size === 0) return
    const count = selectedUris.size
    if (!confirm(`Delete ${count} ${count === 1 ? 'gallery' : 'galleries'}? This cannot be undone.`)) return
    deleting = true
    const uris = [...selectedUris]
    deleteProgress = { current: 0, total: uris.length }
    let deleted = 0
    const failed = new Set<string>()
    for (const uri of uris) {
      const rkey = uri.split('/').pop()!
      try {
        await callXrpc('social.grain.unspecced.deleteGallery', { rkey })
        deleted++
      } catch (err) {
        console.error('Failed to delete gallery:', err)
        failed.add(uri)
      }
      deleteProgress.current++
    }
    deleting = false
    queryClient.invalidateQueries({ queryKey: ['getFeed'] })
    if (failed.size > 0) {
      selectedUris = failed
      toastMessage = `Deleted ${deleted} of ${count}. ${failed.size} failed.`
    } else {
      exitSelectMode()
      toastMessage = `Deleted ${deleted} ${deleted === 1 ? 'gallery' : 'galleries'}`
    }
    showToast = true
  }

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

  const blockHide = $derived(!!profile.data?.viewer?.blocking || !!profile.data?.viewer?.blockedBy)

  const showGermButton = $derived.by(() => {
    const p = profile.data as any
    if (!p?.messageMe || !viewerDid) return false
    if (isOwnProfile) return true
    const policy = p.messageMe.showButtonTo
    if (policy === 'everyone') return true
    if (policy === 'usersIFollow') return !!p.viewer?.followedBy
    return false
  })
  const germUrl = $derived.by(() => {
    const p = profile.data as any
    if (!p?.messageMe?.messageMeUrl || !viewerDid) return null
    return `${p.messageMe.messageMeUrl}/web#${viewerDid}+${did}`
  })
</script>

<div class="page-wrapper">
{#if profile.isLoading}
  <div class="mobile-back"><DetailHeader label={'\u00A0'} /></div>
  <div class="profile-header">
    <div class="profile-info">
      <div class="avatar-col"><Skeleton circle height="var(--avatar-size)" /></div>
      <div class="meta-col">
        <div><Skeleton width="200px" height="26px" /></div>
        <div style="margin-top: 8px"><Skeleton width="140px" height="14px" /></div>
      </div>
    </div>
  </div>
{:else if profile.data && !profile.data.cid}
  <OGMeta title="Profile not found on Grain" noindex />
  <DetailHeader label="Not Found" />
  <div class="not-found">
    <p>This user doesn't have a Grain profile yet.</p>
    <a class="bsky-link" href="https://bsky.app/profile/{did}" target="_blank" rel="noopener noreferrer">
      View on Bluesky <ArrowUpRight size={14} />
    </a>
  </div>
{:else if profile.data}
  {@const p = profile.data}
  {@const name = p.displayName || p.handle || 'Profile'}
  <OGMeta
    title="{name} (@{p.handle || did}) on Grain"
    description={snippet(p.description) ?? `Photo galleries by ${name} (@${p.handle || did}) on Grain.`}
    image="/og/profile/{did}"
    imageAlt="Recent photos by {name}"
    url="{page.url.origin}{profileHref}"
    type="profile"
    noindex={labelsBlockIndexing(p.labels)}
    jsonLd={profileJsonLd(p, page.url.origin, profileHref)}
    at={atTags(`at://${p.did}/social.grain.actor.profile/self`, p.did)}
  />

  <div class="mobile-back"><DetailHeader label={p.displayName || '\u00A0'} /></div>

  <div class="profile-header">
    <div class="actions">
      {#if viewerDid && viewerDid !== did}
        {#if !p.viewer?.blocking && !p.viewer?.blockedBy}
          <FollowButton {did} viewerFollow={p.viewer?.following ?? null} onCountChange={(d) => (followersOffset += d)} />
        {/if}
      {/if}
      <OverflowMenu>
        <button class="menu-item" type="button" onclick={handleShare}>
          <Share size={15} />
          Share
        </button>
        {#if viewerDid && viewerDid !== did}
          <div class="menu-divider"></div>
          {#if !blockHide}
            <button class="menu-item" type="button" onclick={handleMute}>
              <VolumeX size={15} />
              {p.viewer?.muted ? 'Unmute' : 'Mute'}
            </button>
          {/if}
          <button class="menu-item danger" type="button" onclick={handleBlock}>
            <Ban size={15} />
            {p.viewer?.blocking ? 'Unblock' : 'Block'}
          </button>
        {/if}
      </OverflowMenu>
    </div>
    <div class="profile-info">
      <div class="avatar-col">
        <Avatar {did} src={p.avatar ?? null} name={p.displayName} {hasStory} {storyViewed} onclick={hasStory ? () => (showStoryViewer = true) : p.avatar ? () => (lightboxSrc = p.avatar!) : undefined} />
      </div>
      <div class="meta-col">
      <div class="profile-name">{p.displayName || p.handle || did}</div>
      <div class="handle-row">
        {#if !blockHide && p.viewer?.followedBy}<span class="follows-you">Follows you</span>{/if}
        <span class="profile-handle">{p.handle ? `@${p.handle}` : did}</span>
      </div>
      {#if blockHide}
        <div class="block-alert">
          <Ban size={14} />
          {#if p.viewer?.blocking}
            <span>Account blocked</span>
          {:else}
            <span>This user has blocked you</span>
          {/if}
        </div>
      {:else}
        <div class="stat-row">
          <span><strong>{(p.galleryCount ?? 0).toLocaleString()}</strong> {Number(p.galleryCount) === 1 ? 'gallery' : 'galleries'}</span>
          <a href="{profileHref}/followers" class="stat-link"><strong>{((p.followersCount ?? 0) + followersOffset).toLocaleString()}</strong> followers</a>
          <a href="{profileHref}/following" class="stat-link"><strong>{(p.followsCount ?? 0).toLocaleString()}</strong> following</a>
        </div>
        {#if p.description}
          <div class="bio"><RichText text={p.description} /></div>
        {/if}
        {#if group.data || (showGermButton && germUrl)}
          <div class="links-row">
            {#if group.data}
              <a class="link-pill group-pill" href="/group/{did}">
                <UsersRound size={14} /> Group
              </a>
            {/if}
            {#if showGermButton && germUrl}
            <a class="link-pill" href={germUrl} target="_blank" rel="noopener noreferrer">
              <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM <ArrowUpRight size={14} />
            </a>
            {/if}
          </div>
        {/if}
        {#if (knownFollowers.data?.items ?? []).length > 0}
          {@const known = knownFollowers.data?.items ?? []}
          <a href="{profileHref}/known-followers" class="known-followers">
            <Facepile people={known} size={20} />
            <span class="known-text">
              Followed by {known.slice(0, 2).map((k) => k.displayName || k.handle).join(', ')}{#if known.length > 2}{' '}and {known.length - 2} other{known.length - 2 !== 1 ? 's' : ''} you follow{/if}
            </span>
          </a>
        {/if}
      {/if}
      </div>
    </div>
  </div>
{/if}

{#if profile.isError}
  <OGMeta title="Profile not found on Grain" noindex />
  <DetailHeader label="Not Found" />
  <div class="not-found">
    <p>This user doesn't have a Grain profile yet.</p>
    <a class="bsky-link" href="https://bsky.app/profile/{did}" target="_blank" rel="noopener noreferrer">
      View on Bluesky <ArrowUpRight size={14} />
    </a>
  </div>
{:else}
  {#if lightboxSrc}
    <AvatarLightbox src={lightboxSrc} onclose={() => (lightboxSrc = null)} />
  {/if}

  {#if !blockHide}
  <div class="view-toggle">
    <div class="toggle-tabs">
      <button class="toggle-btn" class:active={viewMode === 'grid'} onclick={() => setTab('grid')} aria-label="Grid view">
        <Grid3x3 size={20} />
      </button>
      {#if isOwnProfile}
        <button class="toggle-btn" class:active={viewMode === 'favorites'} onclick={() => setTab('favorites')} aria-label="Favorites">
          <Heart size={20} />
        </button>
        <button class="toggle-btn" class:active={viewMode === 'stories'} onclick={() => setTab('stories')} aria-label="Story archive">
          <Clock size={20} />
        </button>
      {/if}
    </div>
    {#if isOwnProfile && viewMode === 'grid' && !selectMode}
      <button class="select-text-btn" onclick={() => (selectMode = true)}>Select</button>
    {/if}
  </div>

  {#if viewMode === 'stories' && isOwnProfile}
    <StoryArchive {did} />
  {:else if viewMode === 'favorites' && isOwnProfile}
    <GalleryGrid
      items={favoriteItems}
      loading={favorites.isLoading}
      emptyText="No favorites yet."
      hasMore={favorites.hasNextPage}
      loadingMore={favorites.isFetchingNextPage}
      onLoadMore={() => favorites.fetchNextPage()}
    />
  {:else}
    <GalleryGrid
      items={feedItems}
      loading={feed.isLoading}
      hasMore={feed.hasNextPage}
      loadingMore={feed.isFetchingNextPage}
      onLoadMore={() => feed.fetchNextPage()}
      {selectMode}
      {selectedUris}
      onToggle={toggleSelect}
    />
  {/if}
  {/if}

  {#if showStoryViewer}
    <StoryViewer initialDid={did} singleAuthor onclose={() => (showStoryViewer = false)} />
  {/if}
{/if}

<!-- Built and working, but off for now: flip LOGIN_WALL to true to re-enable. -->
{#if LOGIN_WALL && !viewerDid && profile.data?.cid}
  <LoginWall
    {did}
    avatar={profile.data.avatar ?? null}
    name={profile.data.displayName ?? null}
    handle={profile.data.handle ?? null}
  />
{/if}

{#if selectMode}
  <div class="floating-bar">
    {#if deleting}
      <LoaderCircle size={16} class="spin" />
      <span class="bar-text">Deleting {deleteProgress.current} / {deleteProgress.total}...</span>
    {:else}
      <button class="bar-btn cancel" onclick={exitSelectMode}>
        <X size={16} />
        Cancel
      </button>
      <span class="bar-text">{selectedUris.size} selected</span>
      <button class="bar-btn delete" onclick={bulkDelete} disabled={selectedUris.size === 0}>
        <Trash2 size={16} />
        Delete
      </button>
    {/if}
  </div>
{/if}
</div>

<Toast message={toastMessage} bind:visible={showToast} />

<style>
  /* Phones have no left nav, so the profile keeps its back header. Above 600px
     the nav or the public top bar is the way out and this is just noise. */
  .mobile-back { display: none; }
  @media (max-width: 600px) {
    .mobile-back { display: block; }
  }
  .page-wrapper {
    display: flex;
    flex-direction: column;
    min-height: 100%;
  }
  .profile-header { position: relative; }
  .actions { position: absolute; top: 12px; right: 16px; display: flex; gap: 8px; align-items: center; z-index: 1; }
  /* Phones: the avatar sits alone on the top row, then display name, handle,
     bio run full width, and the counts get their own centred band.
     `display: contents` on .meta-col lifts its children into this grid so one
     DOM serves both layouts. */
  .profile-info {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    column-gap: 14px;
    padding: 16px;
  }
  .avatar-col { grid-column: 1; grid-row: 1; display: grid; place-items: center; --avatar-size: 88px; }
  .meta-col { display: contents; }
  .handle-row { grid-column: 1 / -1; }
  .profile-name,
  .bio,
  .links-row,
  .block-alert,
  .stat-row,
  .known-followers { grid-column: 1 / -1; }
  .profile-name { margin-top: 14px; }
  .stat-row { order: 1; }
  .known-followers { order: 2; }

  @media (min-width: 700px) {
    .avatar-col { --avatar-size: 150px; }
    /* Desktop keeps the single right-hand column: .meta-col becomes a normal
       block again, so the placement rules above stop applying. */
    .profile-info {
      grid-template-columns: 290px minmax(0, 1fr);
      column-gap: 16px;
      padding: 34px 16px 26px;
    }
    .meta-col { display: block; min-width: 0; padding-top: 6px; }
    .profile-name { margin-top: 0; }
  }
  .profile-name { font-family: var(--font-display); font-weight: 700; font-size: 20px; margin-top: 10px; }
  .handle-row { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
  .follows-you {
    font-size: 11px; background: var(--border); color: var(--text-secondary);
    padding: 2px 6px; border-radius: 4px;
  }
  .profile-handle { font-size: 13px; color: var(--text-muted); font-family: monospace; word-break: break-all; }
  .stat-row {
    display: flex;
    justify-content: space-around;
    gap: 8px;
    margin-top: 18px;
    padding: 4px 0;
    font-size: 13px;
    color: var(--text-secondary);
  }
  .stat-row > :global(*) {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1px;
  }
  .stat-row :global(strong) { font-size: 16px; }

  @media (min-width: 700px) {
    .stat-row {
      justify-content: flex-start;
      gap: 16px;
      margin-top: 10px;
      padding: 0;
    }
    .stat-row > :global(*) { flex-direction: row; gap: 4px; }
    .stat-row :global(strong) { font-size: inherit; }
  }
  .stat-row strong { color: var(--text-primary); font-weight: 600; }
  .stat-link { text-decoration: none; color: inherit; }
  .stat-link:hover { text-decoration: underline; }
  .bio { margin-top: 8px; font-size: 14px; color: var(--text-secondary); white-space: pre-wrap; }
  .links-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
  .known-followers {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 10px;
    text-decoration: none;
    color: inherit;
  }
  .known-followers:hover .known-text {
    text-decoration: underline;
  }
  .known-text {
    font-size: 12px;
    color: var(--text-muted);
  }
  .link-pill {
    display: inline-flex; align-items: center; gap: 4px; padding: 6px 14px;
    border-radius: 20px; background: var(--bg-surface);
    font-size: 13px; font-weight: 500; color: var(--text-secondary); transition: all 0.12s;
  }
  .link-pill:hover { background: var(--bg-hover); color: var(--text-primary); }
  .germ-logo { width: 14px; height: 14px; object-fit: contain; }
  .group-pill { border-color: var(--grain); color: var(--text-primary); }
  .view-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 8px 16px;
    position: relative;
  }
  .toggle-tabs {
    display: flex;
    gap: 6px;
  }
  .select-text-btn {
    position: absolute;
    right: 16px;
    background: none;
    border: none;
    font-size: 13px;
    font-weight: 500;
    font-family: inherit;
    color: var(--grain);
    cursor: pointer;
    padding: 4px 0;
  }
  .select-text-btn:hover { opacity: 0.8; }
  .toggle-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 9px 18px;
    border-radius: 999px;
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    transition: color 0.15s, background-color 0.15s, box-shadow 0.15s;
  }
  .toggle-btn:hover { color: var(--text-secondary); background: var(--bg-hover); }
  .toggle-btn.active {
    color: var(--text-primary);
    background: var(--bg-surface);
  }
  .not-found { text-align: center; color: var(--text-muted); padding: 48px 16px; font-size: 14px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
  .bsky-link {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    color: var(--grain);
    text-decoration: none;
    font-size: 13px;
    font-weight: 500;
  }
  .bsky-link:hover { text-decoration: underline; }
  .block-alert {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin-top: 10px;
    padding: 6px 12px;
    border-radius: 6px;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    font-size: 13px;
    color: var(--text-muted);
  }
  .menu-item {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 8px 12px;
    border: none;
    background: none;
    color: var(--text-primary);
    font-size: 13px;
    font-family: inherit;
    cursor: pointer;
    border-radius: 6px;
    transition: background 0.15s;
  }
  .menu-item:hover { background: var(--bg-hover); }
  .menu-item.danger { color: var(--danger); }
  .menu-divider { height: 1px; background: var(--border); margin: 4px 0; }
  .floating-bar {
    position: sticky;
    bottom: 0;
    margin-top: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 16px;
    padding: 12px 16px;
    background: var(--bg-blur);
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    border-top: 1px solid var(--border);
    z-index: 100;
  }
  .bar-text {
    font-size: 14px;
    font-weight: 600;
    color: var(--text-primary);
  }
  .bar-btn {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 8px 16px;
    border: none;
    border-radius: 8px;
    font-size: 13px;
    font-weight: 600;
    font-family: inherit;
    cursor: pointer;
    transition: opacity 0.15s;
  }
  .bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
  .bar-btn.cancel {
    background: var(--bg-elevated);
    color: var(--text-primary);
  }
  .bar-btn.delete {
    background: #dc2626;
    color: #fff;
  }
  .bar-btn:not(:disabled):hover { opacity: 0.85; }
</style>