grain.social is a photo sharing platform built on atproto.

add explore page with basic search for users

Changed files
+173 -8
src
+1 -1
deno.json
··· 2 2 "imports": { 3 3 "$lexicon/": "./__generated__/", 4 4 "@atproto/syntax": "npm:@atproto/syntax@^0.4.0", 5 - "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.24", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.25", 6 6 "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", 7 7 "@std/path": "jsr:@std/path@^1.0.9", 8 8 "@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+5 -6
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.24": "0.3.0-beta.24", 5 + "jsr:@bigmoves/bff@0.3.0-beta.25": "0.3.0-beta.25", 6 6 "jsr:@deno/gfm@0.10": "0.10.0", 7 7 "jsr:@denosaurs/emoji@0.3": "0.3.1", 8 8 "jsr:@denosaurs/plug@1": "1.0.5", ··· 13 13 "jsr:@std/assert@^1.0.12": "1.0.13", 14 14 "jsr:@std/assert@^1.0.13": "1.0.13", 15 15 "jsr:@std/async@^1.0.12": "1.0.12", 16 - "jsr:@std/cli@^1.0.16": "1.0.17", 17 16 "jsr:@std/cli@^1.0.17": "1.0.17", 18 17 "jsr:@std/data-structures@^1.0.6": "1.0.7", 19 18 "jsr:@std/encoding@0.214": "0.214.0", ··· 100 99 "npm:tailwind-merge" 101 100 ] 102 101 }, 103 - "@bigmoves/bff@0.3.0-beta.24": { 104 - "integrity": "8ea9b9be5c2a338ce3bda57141b7ea7948a22578275029f225f6b09fb11d18ef", 102 + "@bigmoves/bff@0.3.0-beta.25": { 103 + "integrity": "33a71b4d3f8e28832f2caa13dd09fbf799c5142509e16ce934bc4757509b4a3d", 105 104 "dependencies": [ 106 105 "jsr:@bigmoves/atproto-oauth-client", 107 106 "jsr:@std/assert@^1.0.13", ··· 220 219 "@std/http@1.0.16": { 221 220 "integrity": "80c8d08c4bfcf615b89978dcefb84f7e880087cf3b6b901703936f3592a06933", 222 221 "dependencies": [ 223 - "jsr:@std/cli@^1.0.17", 222 + "jsr:@std/cli", 224 223 "jsr:@std/encoding@^1.0.10", 225 224 "jsr:@std/fmt@^1.0.8", 226 225 "jsr:@std/html", ··· 2051 2050 }, 2052 2051 "workspace": { 2053 2052 "dependencies": [ 2054 - "jsr:@bigmoves/bff@0.3.0-beta.24", 2053 + "jsr:@bigmoves/bff@0.3.0-beta.25", 2055 2054 "jsr:@gfx/canvas@~0.5.8", 2056 2055 "jsr:@std/path@^1.0.9", 2057 2056 "npm:@atproto/syntax@0.4",
+1 -1
src/actor.ts
··· 16 16 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 17 17 } 18 18 19 - function profileToView( 19 + export function profileToView( 20 20 record: WithBffMeta<Profile>, 21 21 handle: string, 22 22 ): Un$Typed<ProfileView> {
+1
src/app.tsx
··· 65 65 </h1> 66 66 } 67 67 profile={profile} 68 + showSearch 68 69 showNotifications 69 70 hasNotifications={hasNotifications} 70 71 class="border-zinc-200 dark:border-zinc-800"
+2
src/main.tsx
··· 5 5 import { onError } from "./errors.ts"; 6 6 import * as actionHandlers from "./routes/actions.tsx"; 7 7 import * as dialogHandlers from "./routes/dialogs.tsx"; 8 + import { handler as exploreHandler } from "./routes/explore.tsx"; 8 9 import { handler as galleryHandler } from "./routes/gallery.tsx"; 9 10 import { handler as notificationsHandler } from "./routes/notifications.tsx"; 10 11 import { handler as onboardHandler } from "./routes/onboard.tsx"; ··· 48 49 LoginComponent: LoginPage, 49 50 }), 50 51 route("/", timelineHandler), 52 + route("/explore", exploreHandler), 51 53 route("/notifications", notificationsHandler), 52 54 route("/profile/:handle", profileHandler), 53 55 route("/profile/:handle/gallery/:rkey", galleryHandler),
+163
src/routes/explore.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 + import { Un$Typed } from "$lexicon/util.ts"; 4 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 5 + import { Input } from "@bigmoves/bff/components"; 6 + import { ComponentChildren } from "preact"; 7 + import { profileToView } from "../actor.ts"; 8 + import { getPageMeta } from "../meta.ts"; 9 + import type { State } from "../state.ts"; 10 + 11 + export const handler: RouteHandler = ( 12 + req, 13 + _params, 14 + ctx: BffContext<State>, 15 + ) => { 16 + ctx.requireAuth(); 17 + const url = new URL(req.url); 18 + const query = url.searchParams.get("q") ?? ""; 19 + ctx.state.meta = [{ title: "Explore — Grain" }, ...getPageMeta("/explore")]; 20 + if (query) { 21 + const profileViews = doSearch(query, ctx); 22 + if (req.headers.get("hx-request")) { 23 + if (profileViews.length === 0) { 24 + return ctx.html(<p>No results for "{query}"</p>); 25 + } 26 + return ctx.html( 27 + <SearchResults query={query} profileViews={profileViews} />, 28 + ); 29 + } else { 30 + return ctx.render( 31 + <ExplorePage query={query}> 32 + <SearchResults query={query} profileViews={profileViews} /> 33 + </ExplorePage>, 34 + ); 35 + } 36 + } 37 + if (req.headers.get("hx-request")) { 38 + return ctx.html(<div />); 39 + } 40 + return ctx.render( 41 + <ExplorePage />, 42 + ); 43 + }; 44 + 45 + function ExplorePage( 46 + { query, children }: Readonly< 47 + { query?: string; children?: ComponentChildren } 48 + >, 49 + ) { 50 + return ( 51 + <div class="px-4 mb-4 sm:max-w-[500px]"> 52 + <div class="my-4"> 53 + <Input 54 + name="q" 55 + class="dark:bg-zinc-800 dark:text-white" 56 + placeholder="Search for users" 57 + hx-get="/explore" 58 + hx-target="#search-results" 59 + hx-trigger="input changed delay:500ms, keyup[key=='Enter']" 60 + hx-swap="innerHTML" 61 + hx-push-url="true" 62 + value={query} 63 + autoFocus 64 + /> 65 + </div> 66 + <div id="search-results"> 67 + {children} 68 + </div> 69 + </div> 70 + ); 71 + } 72 + 73 + function SearchResults( 74 + { query, profileViews }: Readonly< 75 + { query: string; profileViews: Un$Typed<ProfileView>[] } 76 + >, 77 + ) { 78 + return ( 79 + <> 80 + <p class="my-4"> 81 + Search for "{query}" 82 + </p> 83 + <ul class="space-y-2"> 84 + {profileViews.map((profile) => ( 85 + <li key={profile.did}> 86 + <a class="flex items-center" href={`/profile/${profile.handle}`}> 87 + <img 88 + src={profile.avatar} 89 + alt={profile.displayName || profile.handle} 90 + className="rounded-full w-8 h-8 mr-2" 91 + /> 92 + <div class="flex flex-col"> 93 + <div class="font-semibold"> 94 + {profile.displayName || profile.handle} 95 + </div> 96 + <div class="text-sm text-zinc-600 dark:text-zinc-500"> 97 + @{profile.handle} 98 + </div> 99 + </div> 100 + </a> 101 + </li> 102 + ))} 103 + </ul> 104 + </> 105 + ); 106 + } 107 + 108 + function doSearch(query: string, ctx: BffContext<State>) { 109 + const actors = ctx.indexService.searchActors(query); 110 + 111 + const { items } = ctx.indexService.getRecords<WithBffMeta<Profile>>( 112 + "social.grain.actor.profile", 113 + { 114 + where: { 115 + OR: [ 116 + ...(actors.length > 0 117 + ? [{ 118 + field: "did", 119 + in: actors.map((actor) => actor.did), 120 + }] 121 + : []), 122 + { 123 + field: "displayName", 124 + contains: query, 125 + }, 126 + { 127 + field: "did", 128 + contains: query, 129 + }, 130 + ], 131 + }, 132 + }, 133 + ); 134 + 135 + const profileMap = new Map<string, WithBffMeta<Profile>>(); 136 + for (const item of items) { 137 + profileMap.set(item.did, item); 138 + } 139 + 140 + const actorMap = new Map(); 141 + actors.forEach((actor) => { 142 + actorMap.set(actor.did, actor); 143 + }); 144 + 145 + const profileViews = []; 146 + 147 + for (const actor of actors) { 148 + if (profileMap.has(actor.did)) { 149 + const profile = profileMap.get(actor.did)!; 150 + profileViews.push(profileToView(profile, actor.handle)); 151 + } 152 + } 153 + 154 + for (const profile of items) { 155 + if (!actorMap.has(profile.did)) { 156 + const handle = ctx.indexService.getActor(profile.did)?.handle; 157 + if (!handle) continue; 158 + profileViews.push(profileToView(profile, handle)); 159 + } 160 + } 161 + 162 + return profileViews; 163 + }