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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
148 lines · 4.7 KB Raw History
<script lang="ts">
  import { blobUrl } from '$lib/utils'

  let {
    did,
    blob = null,
    src = null,
    name = null,
    size = undefined,
    hasStory = false,
    storyViewed = false,
    onclick = undefined,
  }: {
    did: string
    blob?: unknown
    src?: string | null
    name?: string | null
    /** Fixed pixel size. Omit to inherit `--avatar-size` from an ancestor,
        which is how a caller makes the avatar responsive without JS. */
    size?: number | undefined
    hasStory?: boolean
    /** Grey ring: the story is there but has been watched. */
    storyViewed?: boolean
    onclick?: (() => void) | undefined
  } = $props()

  const url = $derived(src || blobUrl(did, blob))
  let imgError = $state(false)
  $effect(() => {
    void url
    imgError = false
  })

  // Only pin the size when one was given; otherwise inherit.
  const sizeVar = $derived(size === undefined ? undefined : `--avatar-size:${size}px`)
</script>

{#snippet avatarContent()}
  <span class="ring-inner">
    {#if url && !imgError}
      <img
        src={url}
        alt=""
        class="avatar img"
        loading="lazy"
        onerror={() => (imgError = true)}
      />
    {:else}
      <!-- No photo: a grey silhouette, as on iOS. Plenty of accounts have no
           avatar, and a letter made every one of them look like a brand badge. -->
      <span class="avatar fallback" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="currentColor">
          <circle cx="12" cy="8" r="4.5" />
          <path d="M3.5 21.5c0-4.6 3.8-7.8 8.5-7.8s8.5 3.2 8.5 7.8z" />
        </svg>
      </span>
    {/if}
  </span>
{/snippet}

{#if onclick}
  <button type="button" class="avatar-btn" class:story-ring={hasStory} class:viewed={hasStory && storyViewed} style={sizeVar} {onclick}>
    {@render avatarContent()}
  </button>
{:else}
  <span class="avatar-wrap" class:story-ring={hasStory} class:viewed={hasStory && storyViewed} style={sizeVar}>
    {@render avatarContent()}
  </span>
{/if}

<style>
  /* Size comes from CSS, not JS. A caller that needs a responsive avatar sets
     --avatar-size in a media query on an ancestor; nothing has to wait for the
     client to measure the viewport, so the server renders the right size. */
  .avatar-btn,
  .avatar-wrap {
    width: var(--avatar-size, 34px);
    height: var(--avatar-size, 34px);
    border-radius: 50%;
    line-height: 0;
    display: inline-flex;
    /* Photo and fallback have different baselines; middle ignores them. */
    vertical-align: middle;
    flex: none;
    padding: 0;
    border: none;
    background: none;
  }
  .avatar-btn { cursor: pointer; }
  .avatar-btn:hover { opacity: 0.85; }

  /* The layers share one grid cell rather than stacking with absolute
     positioning: `inset: 0` resolves against the padding box, so a loaded photo
     painted over the story ring's gap and only the fallback ever showed one. */
  .ring-inner {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    display: grid;
    padding: 0;
    overflow: hidden;
  }
  .avatar {
    grid-area: 1 / 1;
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
  }
  /* The image is never hidden behind client state: the server renders it
     visible, the browser paints nothing until it has decoded, and the quiet
     grey behind it shows through in the meantime. A cached photo is there on
     the first frame with no wait for hydration. */
  .avatar.img {
    background: color-mix(in srgb, var(--text-secondary) 14%, var(--bg-surface));
  }
  .fallback {
    background: color-mix(in srgb, var(--text-secondary) 24%, var(--bg-surface));
    color: color-mix(in srgb, var(--text-secondary) 70%, var(--bg-surface));
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* Tracks the avatar rather than a JS-computed inner size. */
  .fallback svg {
    width: calc(var(--avatar-size, 34px) * 0.5);
    height: calc(var(--avatar-size, 34px) * 0.5);
  }

  /* Band ~3% of the diameter, gap ~3.7%, leaving the photo around 87%.
     Derived from --avatar-size with calc(): percentage padding would resolve
     against the containing block, not the avatar, and blow up inside a wide
     parent. The 2px floors keep small avatars from losing the ring entirely. */
  .story-ring {
    background: linear-gradient(135deg, #c97cf8, var(--grain), #5bf0d6);
    padding: max(2px, calc(var(--avatar-size, 34px) * 0.03));
  }
  .story-ring .ring-inner {
    background: var(--bg-root);
    padding: max(2px, calc(var(--avatar-size, 34px) * 0.037));
  }
  /* Watched to the end: the ring stays so the story is still reachable, but
     it drops to grey, matching the phone apps. */
  .story-ring.viewed {
    background: var(--text-secondary);
    opacity: 0.55;
  }
</style>