0
2
<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>