Read-it-later social network

rename to /<handle>/bookmarks, created TagPill, update styles

Changed files
+188 -136
src
bun.lockb

This is a binary file and will not be displayed.

-1
package.json
··· 30 30 "@atproto/oauth-client-node": "^0.3.8", 31 31 "@oslojs/encoding": "^1.1.0", 32 32 "@tailwindcss/vite": "^4.1.13", 33 - "@tanstack/svelte-query": "^5.90.2", 34 33 "drizzle-orm": "^0.44.5", 35 34 "postgres": "^3.4.7", 36 35 "valibot": "^1.1.0"
+4
src/app.css
··· 14 14 --font-neco: "Neco"; 15 15 --font-comico: "Comico"; 16 16 } 17 + 18 + @utility border-groove { 19 + border-style: groove; 20 + }
+6 -20
src/lib/components/BookmarkCard.svelte
··· 1 1 <script lang="ts"> 2 + import TagPill from "./TagPill.svelte"; 2 3 import type { LexiconCommunityBookmark } from "$lib/utils"; 3 4 4 5 type BookmarkCardProps = { 5 - isOwner: boolean; 6 + isOwner?: boolean; 6 7 bookmark: LexiconCommunityBookmark; 7 8 onTagClick: (tag: string) => void; 8 9 onTagDeleteClick?: (tag: string) => void; 9 10 }; 10 11 11 - let { isOwner, bookmark, onTagClick, onTagDeleteClick }: BookmarkCardProps = $props(); 12 + let { isOwner = false, bookmark, onTagClick, onTagDeleteClick }: BookmarkCardProps = $props(); 12 13 </script> 13 14 14 15 <article class="flex flex-col gap-4 border border-dashed hover:border-solid hover:shadow-lg px-4 py-3 w-fit"> 15 - <a href={bookmark.subject} class="hover:cursor-pointer text-xl visited:text-violet-600">{bookmark.subject}</a> 16 + <a href={bookmark.subject} class="hover:underline hover:cursor-pointer hover:text-shadow-md text-xl visited:text-violet-600">{bookmark.subject}</a> 16 17 {#if bookmark.tags && bookmark.tags.length > 0} 17 - <div class="flex gap-5"> 18 + <div class="flex gap-5 flex-wrap"> 18 19 {#each bookmark.tags as tag} 19 - <div class="relative group"> 20 - {#if isOwner} 21 - <button 22 - onclick={() => onTagDeleteClick?.(tag)} 23 - class="absolute -right-3 -top-3 group-hover:block hover:cursor-pointer hidden border bg-red-500 text-white text-xs px-1" 24 - > 25 - 🗑️ 26 - </button> 27 - {/if} 28 - <button 29 - onclick={() => onTagClick(tag)} 30 - class="bg-gray-200 w-fit px-2 py-1 hover:cursor-pointer font-comico text-sm" 31 - > 32 - {tag} 33 - </button> 34 - </div> 20 + <TagPill {tag} showDeleteButton={isOwner} {onTagClick} {onTagDeleteClick} /> 35 21 {/each} 36 22 </div> 37 23 {:else}
+39
src/lib/components/TagPill.svelte
··· 1 + <script lang="ts"> 2 + type TagPillProps = { 3 + tag: string; 4 + variant?: "menu"; 5 + showDeleteButton?: boolean; 6 + onTagClick?: (tag: string) => void; 7 + onTagDeleteClick?: (tag: string) => void; 8 + } 9 + 10 + let { tag, variant, showDeleteButton, onTagClick, onTagDeleteClick }: TagPillProps = $props(); 11 + </script> 12 + 13 + <div class="relative group flex"> 14 + {#if showDeleteButton && variant !== "menu"} 15 + <button 16 + onclick={() => onTagDeleteClick?.(tag)} 17 + class="absolute -right-3 -top-3 lg:group-hover:block hover:cursor-pointer hidden bg-white hover:bg-red-500/20 text-white text-xs px-1 py-0.5" 18 + > 19 + 20 + </button> 21 + {/if} 22 + <button 23 + onclick={() => onTagClick?.(tag)} 24 + class={[ 25 + variant === "menu" && "hover:bg-red-300", 26 + "bg-gray-200 w-fit px-2 py-1 hover:cursor-pointer font-comico text-sm" 27 + ]} 28 + > 29 + {tag} 30 + </button> 31 + {#if showDeleteButton} 32 + <button 33 + onclick={() => onTagDeleteClick?.(tag)} 34 + class="lg:hidden text-xs px-1.5 py-0.5 border-2 border-gray-200" 35 + > 36 + 37 + </button> 38 + {/if} 39 + </div>
-1
src/lib/server/api.ts
··· 1 - import { dev } from "$app/environment"; 2 1 import { SLICES_NETWORK_ACCESS_TOKEN } from "$env/static/private"; 3 2 import type { LexiconCommunityBookmark, SliceItem, SliceList } from "$lib/utils"; 4 3
+24 -21
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from '$app/state'; 3 - import '../app.css'; 3 + import '../app.css'; 4 4 5 5 let { data, children } = $props(); 6 6 const user = $derived(data.user); 7 7 </script> 8 8 9 9 <div class="flex flex-col gap-8 w-screen h-full min-h-screen font-neco"> 10 - <header class="flex items-center w-full gap-4 px-8 py-4 justify-between"> 11 - <nav class="text-lg flex gap-4 items-center"> 12 - <a href="/" class="font-comico text-2xl hover:text-shadow-md">potatonet.app</a> 13 - <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg">🧶</a> 14 - <a href="https://bsky.app/profile/zeu.dev" class="hover:text-shadow-lg">🦋</a> 15 - </nav> 10 + <header class="flex flex-col lg:flex-row lg:items-center w-full gap-4 px-8 py-4 border-b lg:border-none justify-between"> 11 + <a href="/" class="font-comico text-2xl hover:text-shadow-md">potatonet.app</a> 16 12 17 - <div class="flex gap-4 items-center text-lg"> 13 + <div class="flex gap-4 items-center text-lg flex-wrap"> 14 + <nav class="text-lg flex gap-4 flex-wrap items-center border-3 border-groove px-3 py-1.5"> 15 + <a href="/" class="hover:text-shadow-lg hover:underline" title="explore" aria-label="explore">🛰️ explore</a> 16 + <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg" title="source code" aria-label="source code">🧶 source code</a> 17 + <a href="https://bsky.app/profile/zeu.dev" class="hover:text-shadow-lg" title="maker's bluesky" aria-label="maker's bluesky">🦋 maker's bluesky</a> 18 + {#if user} 19 + <a href={`/${user.handle}/bookmarks`} class="hover:text-shadow-lg" aria-label="logged in user's bookmarks">🔖 your bookmarks</a> 20 + {/if} 21 + </nav> 18 22 {#if user} 19 - <a href={`/${user.handle}/home`} class="hover:text-shadow-lg">🏡</a> 20 23 <form action="/?/logout" method="POST"> 21 24 <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 22 25 Logout 23 26 </button> 24 27 </form> 25 28 {:else} 26 - <form action="/?/login" method="POST" class="flex gap-4"> 29 + <form action="/?/login" method="POST" class="flex gap-4 lg:basis-0"> 27 30 <input 28 31 name="handle" 29 32 type="text" 30 33 placeholder="Handle (eg: zeu.dev)" 31 - class="border border-black border-dashed px-3 py-2 hover:shadow-lg focus:shadow-lg" 34 + class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" 32 35 /> 33 - <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 36 + <button type="submit" class="font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 34 37 Login 35 38 </button> 36 39 </form> ··· 39 42 </header> 40 43 41 44 {#key page.url.pathname} 42 - <main class="flex flex-col gap-4 p-8"> 43 - <svelte:boundary> 44 - {@render children()} 45 - 46 - {#snippet pending()} 47 - <p>Page loading...</p> 48 - {/snippet} 49 - </svelte:boundary> 50 - </main> 45 + <main class="flex flex-col gap-4 p-8 pt-0 lg:pt-8"> 46 + <svelte:boundary> 47 + {@render children()} 48 + 49 + {#snippet pending()} 50 + <p>Loading...</p> 51 + {/snippet} 52 + </svelte:boundary> 53 + </main> 51 54 {/key} 52 55 </div> 53 56
+40 -26
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 3 + import TagPill from "$lib/components/TagPill.svelte"; 3 4 import { getAllBookmarks } from "./api/bookmarks/data.remote"; 4 5 5 6 let { data } = $props(); 6 7 let cursor = $state(""); 7 8 const userBookmarksQuery = $derived(getAllBookmarks({ cursor })); 9 + const queryData = $derived(userBookmarksQuery.current); 8 10 9 11 let query = $state(""); 10 12 let filterTags = $state<string[]>([]); ··· 21 23 } 22 24 </script> 23 25 24 - <h1 class="text-3xl font-bold font-comico">explore</h1> 26 + <div class="flex gap-4 items-center"> 27 + <h1 class="text-2xl lg:text-3xl font-comico">Explore</h1> 28 + <h2 class="text-lg italic">recent 50</h2> 29 + </div> 30 + 31 + <menu class="flex flex-col lg:flex-row w-full gap-4"> 32 + <label class="flex items-center gap-2"> 33 + Search URLs: 34 + <input type="text" bind:value={query} class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" placeholder="recipe" /> 35 + </label> 36 + 37 + <label class="flex items-center gap-2"> 38 + Tags: 39 + {#if filterTags.length === 0} 40 + <TagPill tag="all" /> 41 + {:else} 42 + {#each filterTags as filtered} 43 + <TagPill showDeleteButton tag={filtered} {onTagClick} onTagDeleteClick={onTagClick} variant="menu" /> 44 + {/each} 45 + {/if} 46 + </label> 47 + <button 48 + onclick={() => userBookmarksQuery.refresh()} 49 + class="font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2" 50 + > 51 + Refresh 52 + </button> 53 + {#if data.user} 54 + <button class="justify-self-end font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 55 + 🔖 New Bookmark 56 + </button> 57 + {/if} 58 + 59 + </menu> 60 + <hr /> 25 61 26 62 {#if userBookmarksQuery.loading} 27 63 <p>Loading...</p> 28 64 {:else if userBookmarksQuery.error} 29 65 <p>Error</p> 30 - {:else} 31 - {@const { cursor: returnedCursor, bookmarks } = userBookmarksQuery.current || { cursor: "", bookmarks: []}} 32 - 33 - <div class="sticky top-0 flex flex-col gap-4 pt-4 bg-white z-50"> 34 - <menu class="flex justify-between font-comico"> 35 - <div class="flex gap-4"> 36 - <label class="flex items-center gap-2"> 37 - Search term: 38 - <input type="text" bind:value={query} class="font-neco border px-2 py-1" placeholder="recipe" /> 39 - </label> 40 - 41 - <label class="flex items-center gap-2"> 42 - Tags: 43 - {#each filterTags as filtered} 44 - <button onclick={() => onTagClick(filtered)}>{filtered}</button> 45 - {/each} 46 - </label> 47 - <button onclick={() => userBookmarksQuery.refresh()}>Refresh</button> 48 - </div> 49 - </menu> 50 - <hr /> 51 - </div> 52 - 66 + {:else if queryData} 53 67 <div class="flex flex-wrap gap-4"> 54 - {#each bookmarks as bookmark} 68 + {#each queryData.bookmarks as bookmark} 55 69 {#if bookmark.subject.includes(query) && (bookmark.tags?.some(t => filterTags.length > 0 ? filterTags.includes(t) : true))} 56 - <BookmarkCard isOwner={false} {bookmark} {onTagClick} {onTagDeleteClick} /> 70 + <BookmarkCard {bookmark} {onTagClick} {onTagDeleteClick} /> 57 71 {/if} 58 72 {/each} 59 73 </div>
+75
src/routes/[handle]/bookmarks/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 4 + import TagPill from "$lib/components/TagPill.svelte"; 5 + import { getUserBookmarks } from "../../api/bookmarks/data.remote"; 6 + 7 + let { data } = $props(); 8 + const { handle } = page.params; 9 + let isOwner = $derived(data.user?.handle === handle); 10 + let cursor = $state(""); 11 + const userBookmarksQuery = $derived(getUserBookmarks({ handle: handle as string, cursor })); 12 + 13 + let query = $state(""); 14 + let filterTags = $state<string[]>([]); 15 + 16 + function onTagClick(tag: string) { 17 + const index = filterTags.findIndex((t) => t === tag); 18 + if (index >= 0) { filterTags.splice(index, 1); } 19 + else { 20 + filterTags.push(tag); 21 + } 22 + } 23 + 24 + function onTagDeleteClick(tag: string) { 25 + console.log("DELETE", tag); 26 + } 27 + </script> 28 + 29 + <h1 class="text-2xl lg:text-3xl font-comico">Bookmarks by @{handle}</h1> 30 + 31 + <menu class="flex flex-col lg:flex-row w-full gap-4"> 32 + <label class="flex items-center gap-2"> 33 + Search URLs: 34 + <input type="text" bind:value={query} class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" placeholder="recipe" /> 35 + </label> 36 + 37 + <label class="flex items-center gap-2"> 38 + Tags: 39 + {#if filterTags.length === 0} 40 + <TagPill tag="all" /> 41 + {:else} 42 + {#each filterTags as filtered} 43 + <TagPill showDeleteButton tag={filtered} {onTagClick} onTagDeleteClick={onTagClick} variant="menu" /> 44 + {/each} 45 + {/if} 46 + </label> 47 + <button 48 + onclick={() => userBookmarksQuery.refresh()} 49 + class="font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2" 50 + > 51 + Refresh 52 + </button> 53 + {#if isOwner} 54 + <button class="justify-self-end font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 55 + 🔖 New Bookmark 56 + </button> 57 + {/if} 58 + 59 + </menu> 60 + <hr /> 61 + 62 + {#if userBookmarksQuery.loading} 63 + <p>Loading...</p> 64 + {:else if userBookmarksQuery.error} 65 + <p>Error</p> 66 + {:else} 67 + {@const { cursor: returnedCursor, bookmarks } = userBookmarksQuery.current || { cursor: "", bookmarks: []}} 68 + <div class="flex flex-wrap gap-4"> 69 + {#each bookmarks as bookmark} 70 + {#if bookmark.subject.includes(query) && (bookmark.tags?.every(t => filterTags.length > 0 ? filterTags.includes(t) : true))} 71 + <BookmarkCard {isOwner} {bookmark} {onTagClick} {onTagDeleteClick} /> 72 + {/if} 73 + {/each} 74 + </div> 75 + {/if}
-67
src/routes/[handle]/home/+page.svelte
··· 1 - <script lang="ts"> 2 - import { page } from "$app/state"; 3 - import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 4 - import { getUserBookmarks } from "../../api/bookmarks/data.remote"; 5 - 6 - let { data } = $props(); 7 - const { handle } = page.params; 8 - let isOwner = $derived(data.user?.handle === handle); 9 - let cursor = $state(""); 10 - const userBookmarksQuery = $derived(getUserBookmarks({ handle: handle as string, cursor })); 11 - 12 - let query = $state(""); 13 - let filterTags = $state<string[]>([]); 14 - 15 - function onTagClick(tag: string) { 16 - const index = filterTags.findIndex((t) => t === tag); 17 - if (index >= 0) { filterTags.splice(index, 1); } 18 - else { 19 - filterTags.push(tag); 20 - } 21 - } 22 - 23 - function onTagDeleteClick(tag: string) { 24 - console.log("DELETE", tag); 25 - } 26 - </script> 27 - 28 - <h1 class="text-3xl font-comico">Bookmarks by @{handle}</h1> 29 - 30 - {#if userBookmarksQuery.loading} 31 - <p>Loading...</p> 32 - {:else if userBookmarksQuery.error} 33 - <p>Error</p> 34 - {:else} 35 - {@const { cursor: returnedCursor, bookmarks } = userBookmarksQuery.current || { cursor: "", bookmarks: []}} 36 - <menu class="flex justify-between"> 37 - <div class="flex gap-4"> 38 - <label class="flex items-center gap-2"> 39 - Search term: 40 - <input type="text" bind:value={query} class="border px-2 py-1" placeholder="recipe" /> 41 - </label> 42 - 43 - <label class="flex items-center gap-2"> 44 - Tags: 45 - {#each filterTags as filtered} 46 - <button onclick={() => onTagClick(filtered)}>{filtered}</button> 47 - {/each} 48 - </label> 49 - <button onclick={() => userBookmarksQuery.refresh()}>Refresh</button> 50 - </div> 51 - 52 - {#if isOwner} 53 - <button class="font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 54 - 🔖 New Bookmark 55 - </button> 56 - {/if} 57 - </menu> 58 - <hr /> 59 - 60 - <div class="flex flex-wrap gap-4"> 61 - {#each bookmarks as bookmark} 62 - {#if bookmark.subject.includes(query) && (bookmark.tags?.some(t => filterTags.length > 0 ? filterTags.includes(t) : true))} 63 - <BookmarkCard {isOwner} {bookmark} {onTagClick} {onTagDeleteClick} /> 64 - {/if} 65 - {/each} 66 - </div> 67 - {/if}