Something went wrong. Try again.
A little atproto app for organizing and posting meme images
Something went wrong. Try again.
7.4 kB · 279 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280<script lang="ts"> import { onMount } from 'svelte'; import { listCollections, createCollection, deleteCollection, type CollectionItem_, type MemeItem } from '../lib/records.js'; import LibraryGrid from './LibraryGrid.svelte'; import CollectionDetail from './CollectionDetail.svelte';
interface Props { // Forwarded to LibraryGrid/CollectionDetail — fires after an edit/delete // so the caller can bump its own refresh token if it tracks one. onchange?: () => void; // Forwarded down to the "Post" action on a meme card. onpost?: (item: MemeItem) => void; // The collection rkey from the URL (see App.svelte's router), or null // for the plain "All" grid. Source of truth for `selected` below — // App.svelte owns the URL, this component just mirrors it. selectedRkey?: string | null; // Fired whenever the user picks a different collection (or "All", as // null) so App.svelte can push the matching URL. onselectcollection?: (rkey: string | null) => void; } let { onchange, onpost, selectedRkey = null, onselectcollection }: Props = $props();
function rkeyOf(uri: string): string { return uri.split('/').pop()!; }
// 'all' shows the plain library grid; any other value is a collection uri. let selected = $state<string>('all');
// Keeps `selected` (a full at-uri, used for the `collections.find` lookup // below) in sync with the rkey-only URL param once collections have // loaded far enough to resolve it. $effect(() => { if (selectedRkey == null) { selected = 'all'; } else { const match = collections.find((c) => rkeyOf(c.uri) === selectedRkey); if (match) selected = match.uri; } });
function selectCollection(uri: string) { selected = uri; onselectcollection?.(uri === 'all' ? null : rkeyOf(uri)); }
let collections = $state<CollectionItem_[]>([]); let loading = $state(true); let error = $state<string | null>(null);
let showNewForm = $state(false); let newName = $state(''); let newDescription = $state(''); let creating = $state(false);
onMount(loadCollections);
async function loadCollections() { loading = true; error = null; try { collections = await listCollections(); } catch (err) { error = err instanceof Error ? err.message : 'Could not load collections.'; } finally { loading = false; } }
async function handleCreate(e: Event) { e.preventDefault(); if (!newName.trim()) return; creating = true; error = null; try { const { uri } = await createCollection({ name: newName.trim(), ...(newDescription.trim() ? { description: newDescription.trim() } : {}), }); newName = ''; newDescription = ''; showNewForm = false; await loadCollections(); selectCollection(uri); } catch (err) { error = err instanceof Error ? err.message : 'Could not create collection.'; } finally { creating = false; } }
async function handleDelete(uri: string) { if (!confirm('Delete this collection? Memes inside it are not deleted.')) return; try { await deleteCollection(uri); collections = collections.filter((c) => c.uri !== uri); if (selected === uri) selectCollection('all'); } catch (err) { error = err instanceof Error ? err.message : 'Delete failed.'; } }
const selectedCollection = $derived(collections.find((c) => c.uri === selected) ?? null);</script>
<div class="library-layout"> <aside class="nav plate"> <div class="nav-top"> <button type="button" class="all-btn" class:active={selected === 'all'} onclick={() => selectCollection('all')}> All </button> <button type="button" class="new-collection-btn" onclick={() => (showNewForm = !showNewForm)}> {showNewForm ? 'Cancel' : '+ New collection'} </button> </div>
{#if showNewForm} <form class="new-collection" onsubmit={handleCreate}> <label class="field"> <span class="wall-label">Name</span> <input type="text" bind:value={newName} disabled={creating} /> </label> <label class="field"> <span class="wall-label">Description</span> <input type="text" bind:value={newDescription} disabled={creating} /> </label> <button class="accent" type="submit" disabled={creating || !newName.trim()}> {creating ? 'Creating…' : 'Create'} </button> </form> {/if}
{#if loading} <p class="wall-label">Loading…</p> {:else if error} <p class="error" role="alert">{error}</p> {:else if collections.length === 0} <p class="wall-label">No collections yet.</p> {:else} <div class="group"> <ul> {#each collections as c (c.uri)} <li> <button type="button" class:active={c.uri === selected} onclick={() => selectCollection(c.uri)}> {c.value.name} </button> </li> {/each} </ul> </div> {/if}
</aside>
<div class="detail-pane"> {#if selected === 'all'} <LibraryGrid {onchange} {onpost} /> {:else if selectedCollection} {#key selectedCollection.uri} <CollectionDetail collection={selectedCollection} ondelete={() => handleDelete(selectedCollection.uri)} {onpost} /> {/key} {/if} </div></div>
<style> .library-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
.nav { flex: 0 0 220px; display: flex; flex-direction: column; gap: 1rem; /* Stays put while the meme grid beside it scrolls — main's own top padding (1.5rem) is repeated here so the sticky offset matches the breathing room it already has at rest. */ position: sticky; top: 1.5rem; max-height: calc(100vh - 3rem); overflow-y: auto; }
.nav-top { display: flex; flex-direction: column; gap: 0.2rem; }
.all-btn { text-align: left; border: none; padding: 0.4em; border-radius: var(--radius-sm); font-weight: 600; }
.all-btn.active { background: var(--ink); color: var(--paper); }
.group ul { list-style: none; margin: 0.4rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.nav .group button { width: 100%; text-align: left; border: none; padding: 0.35em 0.4em; border-radius: var(--radius-sm); font-family: var(--font-label); font-style: italic; }
.nav .group button.active { background: var(--ink); color: var(--paper); }
.new-collection-btn { width: 100%; text-align: left; border: none; padding: 0.35em 0.4em; border-radius: var(--radius-sm); font-size: 0.9rem; color: var(--ink-soft); }
.new-collection { display: flex; flex-direction: column; gap: 0.7rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.detail-pane { flex: 1; min-width: 0; }
.error { font-size: 0.9rem; color: var(--danger); }
@media (max-width: 720px) { .library-layout { flex-direction: column; } .nav { flex: none; width: 100%; position: static; max-height: none; overflow-y: visible; } }</style>