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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
695 lines · 17.6 KB Raw History

Story Archive Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Let users browse all their past stories (beyond 24h) in a thumbnail grid on their own profile page.

Architecture: New XRPC endpoint mirrors getStories without the 24-hour cutoff, with cursor pagination. A StoryArchive grid component renders thumbnails inline on the profile page (own profile only). StoryViewer gains a single-story mode for opening archived stories.

Tech Stack: hatk (XRPC server framework), Svelte 5, TanStack Query, existing StoryView types.


Task 1: Lexicon + Server Endpoint

Files:

  • Create: lexicons/social/grain/unspecced/getStoryArchive.json
  • Create: server/xrpc/getStoryArchive.ts

Step 1: Create the lexicon file

Create lexicons/social/grain/unspecced/getStoryArchive.json:

{
  "lexicon": 1,
  "id": "social.grain.unspecced.getStoryArchive",
  "defs": {
    "main": {
      "type": "query",
      "description": "Get all stories for an actor, including expired ones. For archive browsing.",
      "parameters": {
        "type": "params",
        "required": ["actor"],
        "properties": {
          "actor": { "type": "string", "format": "did" },
          "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 },
          "cursor": { "type": "string" }
        }
      },
      "output": {
        "encoding": "application/json",
        "schema": {
          "type": "object",
          "required": ["stories"],
          "properties": {
            "stories": {
              "type": "array",
              "items": { "type": "ref", "ref": "social.grain.story.defs#storyView" }
            },
            "cursor": { "type": "string" }
          }
        }
      }
    }
  }
}

Step 2: Create the server endpoint

Create server/xrpc/getStoryArchive.ts. This mirrors server/xrpc/getStories.ts with these differences:

  • No 24-hour cutoff
  • ORDER BY created_at DESC (newest first, not ASC)
  • Cursor-based pagination using created_at timestamp
import { defineQuery } from "$hatk";
import { views } from "$hatk";
import type { GrainActorProfile, Story, Label } from "$hatk";
import { HIDE_LABELS } from "../labels/_hidden.ts";
import { lookupCrossPosts } from "../feeds/_hydrate.ts";

export default defineQuery("social.grain.unspecced.getStoryArchive", async (ctx) => {
  const { db, ok } = ctx;
  const actor = ctx.params.actor;
  if (!actor) return ok({ stories: [] });

  const limit = Math.min(Number(ctx.params.limit) || 50, 100);
  const cursor = ctx.params.cursor as string | undefined;

  const queryParams: (string | number)[] = [actor, limit + 1];
  let cursorClause = "";
  if (cursor) {
    cursorClause = ` AND s.created_at < $3`;
    queryParams.push(cursor);
  }

  const rows = (await db.query(
    `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at
       FROM "social.grain.story" s
       LEFT JOIN _repos r ON s.did = r.did
       WHERE s.did = $1
         AND (r.status IS NULL OR r.status != 'takendown')
         ${cursorClause}
       ORDER BY s.created_at DESC
       LIMIT $2`,
    queryParams,
  )) as {
    uri: string;
    cid: string;
    did: string;
    media: string;
    aspect_ratio: string;
    location: string | null;
    address: string | null;
    created_at: string;
  }[];

  const hasMore = rows.length > limit;
  const pageRows = hasMore ? rows.slice(0, limit) : rows;

  // Resolve author profile
  const profiles = await ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [
    actor,
  ]);
  const author = profiles.get(actor);
  const profileView = author
    ? views.grainActorDefsProfileView({
        cid: author.cid,
        did: author.did,
        handle: author.handle ?? author.did,
        displayName: author.value.displayName,
        avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined,
      })
    : views.grainActorDefsProfileView({
        cid: "",
        did: actor,
        handle: actor,
      });

  // Hydrate external labels
  const storyUris = pageRows.map((r) => r.uri);
  const labelsByUri =
    storyUris.length > 0
      ? ((await ctx.labels(storyUris)) as Map<string, Label[]>)
      : new Map<string, Label[]>();

  // Filter stories with hide-severity labels
  const visibleRows = pageRows.filter((row) => {
    const labels = labelsByUri.get(row.uri);
    if (!labels) return true;
    const latestByVal = new Map<string, Label>();
    for (const l of labels) {
      const prev = latestByVal.get(l.val);
      if (!prev || l.cts > prev.cts) latestByVal.set(l.val, l);
    }
    return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg);
  });

  // Cross-post lookup
  const crossPosts = await lookupCrossPosts(db, visibleRows, "story");

  const stories = visibleRows.map((row) => {
    let blobRef: any;
    try {
      blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media;
    } catch {
      blobRef = row.media;
    }

    let aspectRatio: { width: number; height: number };
    try {
      aspectRatio =
        typeof row.aspect_ratio === "string" ? JSON.parse(row.aspect_ratio) : row.aspect_ratio;
    } catch {
      aspectRatio = { width: 4, height: 3 };
    }

    let location: Story["location"] | null = null;
    if (row.location) {
      try {
        location = typeof row.location === "string" ? JSON.parse(row.location) : row.location;
      } catch {
        location = null;
      }
    }

    let address: Story["address"] | null = null;
    if (row.address) {
      try {
        address = typeof row.address === "string" ? JSON.parse(row.address) : row.address;
      } catch {
        address = null;
      }
    }

    return views.storyView({
      uri: row.uri,
      cid: row.cid,
      creator: profileView,
      thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "",
      fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "",
      aspectRatio,
      ...(location
        ? {
            location: { name: location.name, value: location.value },
            ...(address ? { address } : {}),
          }
        : {}),
      createdAt: row.created_at,
      ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}),
      ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}),
    });
  });

  const nextCursor = hasMore ? pageRows[pageRows.length - 1].created_at : undefined;

  return ok({ stories, ...(nextCursor ? { cursor: nextCursor } : {}) });
});

Step 3: Regenerate types

Run: npx hatk generate types Expected: Output includes GetStoryArchive in the generated types list.

Step 4: Commit

git add lexicons/social/grain/unspecced/getStoryArchive.json server/xrpc/getStoryArchive.ts hatk.generated.ts hatk.generated.client.ts
git commit -m "feat: add getStoryArchive endpoint (no 24h cutoff, cursor pagination)"

Task 2: Client Query

Files:

  • Modify: app/lib/queries.ts (add after storiesQuery around line 89)

Step 1: Add storyArchiveQuery to queries.ts

Add after the existing storiesQuery definition:

export const storyArchiveQuery = (did: string, cursor?: string, f?: Fetch) =>
  queryOptions({
    queryKey: ["stories", "archive", did, cursor],
    queryFn: () =>
      callXrpc(
        "social.grain.unspecced.getStoryArchive",
        { actor: did, ...(cursor ? { cursor } : {}) },
        f,
      ).then((r) => r ?? { stories: [], cursor: undefined }),
    staleTime: 60_000,
  });

Step 2: Verify types

Run: npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED' Expected: 0 ERRORS

Step 3: Commit

git add app/lib/queries.ts
git commit -m "feat: add storyArchiveQuery client query"

Task 3: StoryViewer — Single-Story Mode + Date Formatting

Files:

  • Modify: app/lib/components/organisms/StoryViewer.svelte

Step 1: Add singleStory prop

Add a new optional prop to the $props() destructuring at lines 12-18:

let {
  initialDid,
  onclose,
  singleStory,
}: {
  initialDid: string;
  onclose: () => void;
  singleStory?: { uri: string } | null;
} = $props();

Step 2: Override story loading in single-story mode

When singleStory is provided, use storyQuery instead of storiesQuery and disable author swiping.

Add a new import at line 6:

import { storiesQuery, storyAuthorsQuery, storyQuery } from "$lib/queries";

Add after the storyAuthors query (around line 23):

// Single-story mode: load just one story by URI
const singleStoryData = createQuery(() => ({
  ...storyQuery(singleStory?.uri ?? ""),
  enabled: !!singleStory,
}));

Override stories and authorDids derivations. Replace the existing stories line (line 55) and totalStories (line 58) with:

const stories = createQuery(() =>
  singleStory ? { ...storyQuery(singleStory.uri), enabled: true } : storiesQuery(currentDid),
);

const currentStory = $derived(
  singleStory ? (singleStoryData.data ?? undefined) : stories.data?.[currentStoryIndex],
);
const totalStories = $derived(singleStory ? 1 : (stories.data?.length ?? 0));

Step 3: Disable author swiping in single-story mode

In the next() function (line 148), wrap the author-advance logic:

function next() {
  if (currentStoryIndex < totalStories - 1) {
    currentStoryIndex++;
    progress = 0;
  } else if (!singleStory && currentAuthorIndex < authorDids.length - 1) {
    currentAuthorIndex++;
  } else {
    onclose();
  }
}

In the prev() function (line 160):

function prev() {
  if (currentStoryIndex > 0) {
    currentStoryIndex--;
    progress = 0;
  } else if (!singleStory && currentAuthorIndex > 0) {
    currentAuthorIndex--;
  }
}

Step 4: Update timeAgo for old stories

Replace the timeAgo function (lines 119-127) with:

function timeAgo(dateStr: string): string {
  const diff = Date.now() - new Date(dateStr).getTime();
  const hours = Math.floor(diff / (1000 * 60 * 60));
  if (hours < 1) {
    const mins = Math.floor(diff / (1000 * 60));
    return `${mins}m`;
  }
  if (hours < 24) {
    return `${hours}h`;
  }
  return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}

Step 5: Verify types

Run: npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED' Expected: 0 ERRORS

Step 6: Commit

git add app/lib/components/organisms/StoryViewer.svelte
git commit -m "feat: add single-story mode and full date display to StoryViewer"

Task 4: StoryArchive Grid Component

Files:

  • Create: app/lib/components/molecules/StoryArchive.svelte

Step 1: Create the component

This component shows a 3-column grid of square story thumbnails with date overlays. It uses cursor pagination with a "Load more" button. Tapping a thumbnail opens StoryViewer in single-story mode.

<script lang="ts">
  import { createQuery } from '@tanstack/svelte-query'
  import type { StoryView } from '$hatk/client'
  import { storyArchiveQuery } from '$lib/queries'
  import StoryViewer from '$lib/components/organisms/StoryViewer.svelte'
  import { callXrpc } from '$hatk/client'

  let { did }: { did: string } = $props()

  const initial = createQuery(() => storyArchiveQuery(did))

  let allStories = $state<StoryView[]>([])
  let cursor = $state<string | undefined>(undefined)
  let loadingMore = $state(false)
  let viewingStory = $state<{ uri: string } | null>(null)

  // Sync initial query data into local state
  $effect(() => {
    const data = initial.data as { stories?: StoryView[]; cursor?: string } | undefined
    if (data?.stories) {
      allStories = data.stories
      cursor = data.cursor
    }
  })

  async function loadMore() {
    if (!cursor || loadingMore) return
    loadingMore = true
    try {
      const result = await callXrpc('social.grain.unspecced.getStoryArchive', { actor: did, cursor }) as { stories?: StoryView[]; cursor?: string }
      allStories = [...allStories, ...(result.stories ?? [])]
      cursor = result.cursor
    } finally {
      loadingMore = false
    }
  }

  function formatDate(dateStr: string): string {
    return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
  }
</script>

{#if initial.isLoading}
  <div class="archive-grid">
    {#each { length: 6 } as _}
      <div class="cell placeholder"></div>
    {/each}
  </div>
{:else if allStories.length === 0}
  <div class="empty">No stories yet.</div>
{:else}
  <div class="archive-grid">
    {#each allStories as story (story.uri)}
      <button class="cell" onclick={() => (viewingStory = { uri: story.uri })}>
        <img
          src={story.thumb}
          alt=""
          decoding="async"
          loading="lazy"
          onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')}
        />
        <span class="date-badge">{formatDate(story.createdAt)}</span>
      </button>
    {/each}
  </div>

  {#if cursor}
    <div class="load-more">
      <button class="load-more-btn" onclick={loadMore} disabled={loadingMore}>
        {loadingMore ? 'Loading…' : 'Load more'}
      </button>
    </div>
  {/if}
{/if}

{#if viewingStory}
  <StoryViewer initialDid={did} singleStory={viewingStory} onclose={() => (viewingStory = null)} />
{/if}

<style>
  .archive-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 2px;
  }
  .cell {
    display: block;
    aspect-ratio: 1;
    background: var(--bg-elevated);
    position: relative;
    overflow: hidden;
    border: none;
    padding: 0;
    cursor: pointer;
  }
  .cell img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    transition: opacity 0.2s ease;
  }
  .cell img:global(.loaded) {
    opacity: 1;
  }
  .placeholder {
    animation: pulse 1.5s ease-in-out infinite;
  }
  @keyframes pulse {
    0%, 100% { opacity: 0.4; }
    50% { opacity: 0.7; }
  }
  .date-badge {
    position: absolute;
    bottom: 6px;
    left: 6px;
    font-size: 11px;
    font-weight: 600;
    color: #fff;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
  }
  .load-more {
    display: flex;
    justify-content: center;
    padding: 16px;
  }
  .load-more-btn {
    background: none;
    border: 1px solid var(--border);
    border-radius: 20px;
    padding: 8px 24px;
    font-size: 13px;
    font-weight: 600;
    color: var(--text-secondary);
    cursor: pointer;
    font-family: inherit;
  }
  .load-more-btn:hover {
    background: var(--bg-hover);
    color: var(--text-primary);
  }
  .load-more-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
  .empty {
    padding: 32px;
    text-align: center;
    color: var(--text-muted);
    font-size: 14px;
  }
</style>

Step 2: Verify types

Run: npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED' Expected: 0 ERRORS

Step 3: Commit

git add app/lib/components/molecules/StoryArchive.svelte
git commit -m "feat: add StoryArchive thumbnail grid component"

Task 5: Wire Into Profile Page

Files:

  • Modify: app/routes/profile/[did]/+page.svelte

Step 1: Add imports and state

Add to the imports (after the StoryViewer import, line 15):

import StoryArchive from "$lib/components/molecules/StoryArchive.svelte";
import { Archive } from "lucide-svelte";

Add state variable (after showStoryViewer at line 21):

let showArchive = $state(false);

Add derived (after hasStory at line 30):

const isOwnProfile = $derived(viewerDid === did);

Step 2: Add archive button to the profile header

Insert after the .links-row div (after line 83, before the knownFollowers block):

{#if isOwnProfile}
  <button class="archive-btn" onclick={() => (showArchive = !showArchive)}>
    <Archive size={14} />
    Story Archive
  </button>
{/if}

Step 3: Add archive section to the page body

Insert after the view-toggle section (after line 116, before the grid/list content):

{#if showArchive && isOwnProfile}
  <div class="archive-section">
    <div class="archive-header">
      <h3 class="archive-title">Story Archive</h3>
      <button class="archive-close" onclick={() => (showArchive = false)}>&times;</button>
    </div>
    <StoryArchive {did} />
  </div>
{/if}

Step 4: Add styles

Add to the <style> block:

.archive-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  border-radius: 20px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  cursor: pointer;
  font-family: inherit;
  transition: all 0.12s;
}
.archive-btn:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}
.archive-section {
  border-bottom: 1px solid var(--border);
}
.archive-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
}
.archive-title {
  font-size: 15px;
  font-weight: 600;
  margin: 0;
}
.archive-close {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 20px;
  cursor: pointer;
  padding: 4px 8px;
  line-height: 1;
}
.archive-close:hover {
  color: var(--text-primary);
}

Step 5: Reset archive on profile change

Update the existing $effect at line 24 to also reset archive state:

$effect(() => {
  void did;
  void profile.data;
  followersOffset = 0;
  showArchive = false;
});

Step 6: Verify types

Run: npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED' Expected: 0 ERRORS

Step 7: Commit

git add app/routes/profile/[did]/+page.svelte
git commit -m "feat: add Story Archive section to own profile page"