+1
-1
deno.json
+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
+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
+1
-1
src/actor.ts
+1
src/app.tsx
+1
src/app.tsx
+2
src/main.tsx
+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
+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
+
}