tangled
alpha
login
or
join now
tynanpurdy.com
/
atprofile
3
fork
atom
Fork of atp.tools as a universal profile for people on the ATmosphere
3
fork
atom
overview
issues
pulls
pipelines
fixed vpositioning on a bunch of screens
Natalie B.
1 year ago
eb785a10
4480af37
+445
-50
10 changed files
expand all
collapse all
unified
split
src
components
error.tsx
views
app-bsky
actorProfile.tsx
lib
getDidDoc.ts
preprocess.tsx
utils.ts
routes
at:
$handle
$collection.$rkey.lazy.tsx
$collection.index.lazy.tsx
$handle.index.tsx
counter.lazy.tsx
index.lazy.tsx
+4
-2
src/components/error.tsx
···
6
6
export default function ShowError({ error }: { error: Error }) {
7
7
const router = useRouter();
8
8
return (
9
9
-
<div className="flex flex-col min-h-screen justify-center items-center gap-4">
9
9
+
<div className="flex flex-col max-h-[calc(100vh-5rem)] h-screen justify-center items-center gap-4">
10
10
<div className="flex flex-col gap-2 items-center">
11
11
<CircleAlert className="text-red-500" width={48} height={48} />
12
12
-
<div className="h-min text-wrap break-words max-w-md w-full text-center">Error: {error.message}</div>
12
12
+
<div className="h-min text-wrap break-words max-w-md w-full text-center">
13
13
+
Error: {error.message}
14
14
+
</div>
13
15
</div>
14
16
<div className="flex flex-row gap-2 items-center">
15
17
<Button variant="secondary" onClick={() => router.history.back()}>
+115
src/components/views/app-bsky/actorProfile.tsx
···
1
1
+
import { AppBskyActorProfile } from "@atcute/client/lexicons";
2
2
+
import { CollectionViewComponent, CollectionViewProps } from "../getView";
3
3
+
import { getBlueskyCdnLink } from "@/components/json/appBskyEmbedImages";
4
4
+
import { AtSign, Pin, Tag, WalletCards } from "lucide-react";
5
5
+
import { preprocessText } from "@/lib/preprocess";
6
6
+
import { BlueskyPostWithoutEmbed } from "./embed";
7
7
+
import { Link } from "@tanstack/react-router";
8
8
+
9
9
+
const StarterPackInfo = ({
10
10
+
profile,
11
11
+
}: {
12
12
+
profile: AppBskyActorProfile.Record;
13
13
+
}) => {
14
14
+
if (!profile.joinedViaStarterPack) return null;
15
15
+
16
16
+
const [, , handle, collection, rkey] =
17
17
+
profile.joinedViaStarterPack.uri.split("/");
18
18
+
19
19
+
return (
20
20
+
<>
21
21
+
<WalletCards height="1rem" className="inline" />
22
22
+
<Link
23
23
+
to="/at:/$handle/$collection/$rkey"
24
24
+
params={{ handle, collection, rkey }}
25
25
+
className="text-muted-foreground text-sm mt-4 mb-1"
26
26
+
>
27
27
+
Joined via starter pack: {profile.joinedViaStarterPack.uri}
28
28
+
</Link>
29
29
+
</>
30
30
+
);
31
31
+
};
32
32
+
33
33
+
export const AppBskyActorProfileView: CollectionViewComponent<
34
34
+
CollectionViewProps
35
35
+
> = ({ data, repoData }: CollectionViewProps) => {
36
36
+
const profile = data.value as AppBskyActorProfile.Record;
37
37
+
return (
38
38
+
<>
39
39
+
{profile ? (
40
40
+
profile?.banner ? (
41
41
+
<div className="relative">
42
42
+
<img
43
43
+
src={getBlueskyCdnLink(
44
44
+
repoData?.did!,
45
45
+
profile?.banner?.ref.$link,
46
46
+
"jpeg",
47
47
+
)}
48
48
+
className="w-full rounded-lg -z-10 border object-cover -mb-16"
49
49
+
/>
50
50
+
{profile.avatar ? (
51
51
+
<img
52
52
+
src={getBlueskyCdnLink(
53
53
+
repoData?.did!,
54
54
+
profile?.avatar?.ref.$link,
55
55
+
"jpeg",
56
56
+
)}
57
57
+
className="w-32 h-32 rounded-full object-cover ml-12"
58
58
+
/>
59
59
+
) : (
60
60
+
<div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center ml-12">
61
61
+
<AtSign className="w-16 h-16" />
62
62
+
</div>
63
63
+
)}
64
64
+
</div>
65
65
+
) : profile.avatar ? (
66
66
+
<img
67
67
+
src={getBlueskyCdnLink(
68
68
+
repoData?.did!,
69
69
+
profile?.avatar?.ref.$link,
70
70
+
"jpeg",
71
71
+
)}
72
72
+
className="w-32 h-32 rounded-full object-cover ml-12"
73
73
+
/>
74
74
+
) : (
75
75
+
<div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center ml-12">
76
76
+
<AtSign className="w-16 h-16" />
77
77
+
</div>
78
78
+
)
79
79
+
) : (
80
80
+
<div> Nothing doing!</div>
81
81
+
)}
82
82
+
<div className="ml-12 mt-2">
83
83
+
<h1 className="text-2xl">
84
84
+
{profile.displayName}{" "}
85
85
+
<span className="text-muted-foreground">
86
86
+
{repoData?.handle && "@" + repoData.handle}
87
87
+
{repoData?.handleIsCorrect ? "" : " (invalid)"}
88
88
+
</span>
89
89
+
</h1>
90
90
+
<p>{profile.description && preprocessText(profile.description)}</p>
91
91
+
{profile.labels && (
92
92
+
<div className="text-muted-foreground text-sm mt-4 mb-1">
93
93
+
<Tag height="1rem" className="inline" /> Labels
94
94
+
</div>
95
95
+
)}
96
96
+
<StarterPackInfo profile={profile} />
97
97
+
{profile.labels?.values.map((l) => (
98
98
+
<div className="bg-blue-400 dark:bg-blue-700">{l.val}</div>
99
99
+
))}
100
100
+
{profile.pinnedPost && (
101
101
+
<>
102
102
+
<div className="text-muted-foreground text-sm mt-4 mb-1">
103
103
+
<Pin height="1rem" className="inline" />
104
104
+
Pinned Post{" "}
105
105
+
</div>
106
106
+
<BlueskyPostWithoutEmbed
107
107
+
uri={profile.pinnedPost.uri}
108
108
+
showEmbeddedPost={true}
109
109
+
/>
110
110
+
</>
111
111
+
)}
112
112
+
</div>
113
113
+
</>
114
114
+
);
115
115
+
};
+20
src/lib/getDidDoc.ts
···
1
1
+
import { DidDocument } from "@atcute/client/utils/did";
2
2
+
3
3
+
export default async function getDidDoc(did: string): Promise<DidDocument> {
4
4
+
try {
5
5
+
if (did.startsWith("did:web:")) {
6
6
+
const response = await fetch(
7
7
+
`https://${did.replace("did:web:", "")}/.well-known/did.json`,
8
8
+
);
9
9
+
return await response.json();
10
10
+
} else if (did.startsWith("did:plc")) {
11
11
+
const response = await fetch(`https://plc.directory/${did}`);
12
12
+
return await response.json();
13
13
+
}
14
14
+
throw new Error(`Unsupported DID format: ${did}`);
15
15
+
} catch (error) {
16
16
+
throw new Error(
17
17
+
`Failed to fetch DID document: ${error instanceof Error ? error.message : String(error)}`,
18
18
+
);
19
19
+
}
20
20
+
}
+39
src/lib/preprocess.tsx
···
1
1
+
import React from "preact/compat";
2
2
+
3
3
+
export function preprocessText(text: string): React.ReactNode[] {
4
4
+
// URL regex pattern
5
5
+
const urlPattern = /(https?:\/\/[^\s]+)/g;
6
6
+
7
7
+
// Split the text by URLs
8
8
+
const parts = text.split(urlPattern);
9
9
+
10
10
+
// Process each part and create React elements
11
11
+
return parts.map((part, index) => {
12
12
+
// Check if this part is a URL
13
13
+
if (urlPattern.test(part)) {
14
14
+
return (
15
15
+
<a
16
16
+
className="text-blue-700 dark:text-blue-400"
17
17
+
key={index}
18
18
+
href={part}
19
19
+
target="_blank"
20
20
+
rel="noopener noreferrer"
21
21
+
>
22
22
+
{part}
23
23
+
</a>
24
24
+
);
25
25
+
}
26
26
+
27
27
+
// Handle newlines in text parts
28
28
+
if (part) {
29
29
+
return part.split("\n").map((line, lineIndex, array) => (
30
30
+
<React.Fragment key={`${index}-${lineIndex}`}>
31
31
+
{line}
32
32
+
{lineIndex < array.length - 1 && <br />}
33
33
+
</React.Fragment>
34
34
+
));
35
35
+
}
36
36
+
37
37
+
return null;
38
38
+
});
39
39
+
}
+97
-3
src/lib/utils.ts
···
1
1
-
import { clsx, type ClassValue } from "clsx"
2
2
-
import { twMerge } from "tailwind-merge"
1
1
+
import { clsx, type ClassValue } from "clsx";
2
2
+
import { twMerge } from "tailwind-merge";
3
3
4
4
export function cn(...inputs: ClassValue[]) {
5
5
-
return twMerge(clsx(inputs))
5
5
+
return twMerge(clsx(inputs));
6
6
+
}
7
7
+
type TimeUnit =
8
8
+
| "year"
9
9
+
| "month"
10
10
+
| "week"
11
11
+
| "day"
12
12
+
| "hour"
13
13
+
| "minute"
14
14
+
| "second";
15
15
+
16
16
+
interface TimeInterval {
17
17
+
seconds: number;
18
18
+
label: TimeUnit;
19
19
+
}
20
20
+
21
21
+
interface TimeagoOptions {
22
22
+
maxUnit?: TimeUnit;
23
23
+
minUnit?: TimeUnit;
24
24
+
future?: boolean;
25
25
+
useShortLabels?: boolean;
26
26
+
}
27
27
+
28
28
+
export function timeAgo(
29
29
+
date: Date | string | number,
30
30
+
options: TimeagoOptions = {},
31
31
+
): string {
32
32
+
const {
33
33
+
maxUnit = "year",
34
34
+
minUnit = "second",
35
35
+
future = true,
36
36
+
useShortLabels = false,
37
37
+
} = options;
38
38
+
39
39
+
const currentDate = new Date();
40
40
+
const targetDate = new Date(date);
41
41
+
42
42
+
const seconds = Math.floor(
43
43
+
(currentDate.getTime() - targetDate.getTime()) / 1000,
44
44
+
);
45
45
+
const isFuture = seconds < 0;
46
46
+
const absoluteSeconds = Math.abs(seconds);
47
47
+
48
48
+
const intervals: TimeInterval[] = [
49
49
+
{ seconds: 31536000, label: "year" },
50
50
+
{ seconds: 2592000, label: "month" },
51
51
+
{ seconds: 604800, label: "week" },
52
52
+
{ seconds: 86400, label: "day" },
53
53
+
{ seconds: 3600, label: "hour" },
54
54
+
{ seconds: 60, label: "minute" },
55
55
+
{ seconds: 1, label: "second" },
56
56
+
];
57
57
+
58
58
+
// Short labels mapping
59
59
+
const shortLabels: Record<TimeUnit, string> = {
60
60
+
year: "y",
61
61
+
month: "mo",
62
62
+
week: "w",
63
63
+
day: "d",
64
64
+
hour: "h",
65
65
+
minute: "m",
66
66
+
second: "s",
67
67
+
};
68
68
+
69
69
+
// Handle future dates if not allowed
70
70
+
if (isFuture && !future) {
71
71
+
return "in the future";
72
72
+
}
73
73
+
74
74
+
// Handle just now
75
75
+
if (absoluteSeconds < 30 && minUnit === "second") {
76
76
+
return "just now";
77
77
+
}
78
78
+
79
79
+
// Filter intervals based on max and min units
80
80
+
const filteredIntervals = intervals.filter((interval) => {
81
81
+
const unitIndex = intervals.findIndex((i) => i.label === interval.label);
82
82
+
const maxUnitIndex = intervals.findIndex((i) => i.label === maxUnit);
83
83
+
const minUnitIndex = intervals.findIndex((i) => i.label === minUnit);
84
84
+
return unitIndex >= maxUnitIndex && unitIndex <= minUnitIndex;
85
85
+
});
86
86
+
87
87
+
for (const { seconds: secondsInUnit, label } of filteredIntervals) {
88
88
+
const interval = Math.floor(absoluteSeconds / secondsInUnit);
89
89
+
90
90
+
if (interval >= 1) {
91
91
+
const unitLabel = useShortLabels ? shortLabels[label] : label;
92
92
+
const plural = interval === 1 ? "" : "s";
93
93
+
const timeLabel = `${interval}${useShortLabels ? "" : " "}${unitLabel}${useShortLabels ? "" : plural}`;
94
94
+
95
95
+
return isFuture ? `in ${timeLabel}` : `${timeLabel} ago`;
96
96
+
}
97
97
+
}
98
98
+
99
99
+
return "just now";
6
100
}
+31
-10
src/routes/at:/$handle.index.tsx
···
1
1
import ShowError from "@/components/error";
2
2
+
import { RenderJson } from "@/components/renderJson";
2
3
import RepoIcons from "@/components/repoIcons";
3
4
import { Loader } from "@/components/ui/loader";
4
5
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
6
6
+
import getDidDoc from "@/lib/getDidDoc";
5
7
import { QtClient, useXrpc } from "@/providers/qtprovider";
6
8
import "@atcute/bluesky/lexicons";
7
9
import {
8
10
AppBskyActorGetProfile,
9
11
ComAtprotoRepoDescribeRepo,
10
12
} from "@atcute/client/lexicons";
13
13
+
import { DidDocument } from "@atcute/client/utils/did";
11
14
import {
15
15
+
AuthorizationServerMetadata,
12
16
IdentityMetadata,
13
17
resolveFromIdentity,
14
18
} from "@atcute/oauth-browser-client";
···
19
23
interface RepoData {
20
24
data?: ComAtprotoRepoDescribeRepo.Output;
21
25
blueSkyData?: AppBskyActorGetProfile.Output | null;
22
22
-
identity?: IdentityMetadata;
26
26
+
identity?: {
27
27
+
identity: IdentityMetadata;
28
28
+
metadata: AuthorizationServerMetadata;
29
29
+
};
23
30
isLoading: boolean;
31
31
+
didDoc?: DidDocument;
24
32
error: Error | null;
25
33
}
26
34
···
53
61
params: { repo: id.identity.id },
54
62
signal: abortController.signal,
55
63
});
64
64
+
let doc = await getDidDoc(id.identity.id);
56
65
// can we get bsky data?
57
66
if (response.data.collections.includes("app.bsky.actor.profile")) {
58
67
// reuse client dumbass
···
67
76
setState({
68
77
blueSkyData: bskyData.data,
69
78
data: response.data,
70
70
-
identity: id.identity,
79
79
+
identity: id,
71
80
isLoading: false,
81
81
+
didDoc: doc,
72
82
error: null,
73
83
});
74
84
} else {
75
85
setState({
76
86
blueSkyData: null,
77
87
data: response.data,
78
78
-
identity: id.identity,
88
88
+
identity: id,
79
89
isLoading: false,
90
90
+
didDoc: doc,
80
91
error: null,
81
92
});
82
93
}
···
108
119
109
120
function RouteComponent() {
110
121
const { handle } = Route.useParams();
111
111
-
const { blueSkyData, data, identity, isLoading, error } = useRepoData(handle);
122
122
+
const { blueSkyData, data, identity, isLoading, error, didDoc } =
123
123
+
useRepoData(handle);
112
124
if (error) {
113
125
return <ShowError error={error} />;
114
126
}
115
127
116
128
if (isLoading && !blueSkyData) {
117
117
-
return <Loader className="min-h-screen" />;
129
129
+
return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />;
118
130
}
119
131
120
132
return (
121
121
-
<div className="flex flex-row justify-center w-full min-h-screen">
133
133
+
<div className="flex flex-row justify-center w-full">
122
134
<div className="max-w-2xl w-screen p-4 md:mt-16 space-y-2">
123
135
{blueSkyData ? (
124
136
blueSkyData?.banner ? (
···
132
144
className="absolute -bottom-12 md:-bottom-16 w-24 lg:w-32 aspect-square rounded-full border"
133
145
/>
134
146
</div>
135
135
-
) : (
147
147
+
) : blueSkyData.avatar ? (
136
148
<img src={blueSkyData?.avatar} className="w-32 h-32 rounded-full" />
149
149
+
) : (
150
150
+
<div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center">
151
151
+
<AtSign className="w-16 h-16" />
152
152
+
</div>
137
153
)
138
154
) : (
139
155
<div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center">
···
153
169
<RepoIcons
154
170
collections={data?.collections}
155
171
handle={data?.handle}
156
156
-
did={identity?.id}
172
172
+
did={identity?.identity.id}
157
173
/>
158
174
</div>
159
175
)}
···
161
177
<br />
162
178
163
179
<div>
164
164
-
PDS: {identity?.pds.hostname.includes("bsky.network") && "🍄"}{" "}
165
165
-
{identity?.pds.hostname}
180
180
+
PDS:{" "}
181
181
+
{identity?.identity.pds.hostname.includes("bsky.network") && "🍄"}{" "}
182
182
+
{identity?.identity.pds.hostname}
166
183
</div>
167
184
168
185
<div>
···
182
199
</li>
183
200
))}
184
201
</ul>
202
202
+
</div>
203
203
+
<div className="pt-2">
204
204
+
<h2 className="text-xl font-bold">DID Document</h2>
205
205
+
<RenderJson data={didDoc} did={identity?.identity.id!} />
185
206
</div>
186
207
</div>
187
208
</div>
+52
-6
src/routes/at:/$handle/$collection.$rkey.lazy.tsx
···
114
114
}
115
115
116
116
if (isLoading && !data) {
117
117
-
return <Loader className="min-h-screen" />;
117
117
+
return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />;
118
118
}
119
119
120
120
if (data === undefined) return <div>No data</div>;
···
146
146
</div>
147
147
{!View && (
148
148
<div className="text-muted-foreground text-xs">
149
149
-
if you see this message please bug me to add a custom view for this
150
150
-
repo type
149
149
+
This View is not yet implemented. If you have a need, state your
150
150
+
case{" "}
151
151
+
<Link
152
152
+
to="/at:/$handle"
153
153
+
params={{ handle: "natalie.sh" }}
154
154
+
className="text-blue-700 dark:text-blue-400"
155
155
+
>
156
156
+
@natalie.sh
157
157
+
</Link>
151
158
</div>
152
159
)}
160
160
+
<div className="text-muted-foreground group">
161
161
+
<Link
162
162
+
to={`/at:/$handle`}
163
163
+
params={{
164
164
+
handle: repoInfo?.did || "",
165
165
+
}}
166
166
+
className="dark:hover:text-blue-400 group-hover:text-blue-500 transition-colors duration-300"
167
167
+
>
168
168
+
at://{handle}
169
169
+
</Link>
170
170
+
<Link
171
171
+
to={`/at:/$handle/$collection`}
172
172
+
params={{
173
173
+
handle: repoInfo?.did || "",
174
174
+
collection,
175
175
+
}}
176
176
+
className="dark:hover:text-blue-400 group-hover:text-blue-500 transition-colors duration-300"
177
177
+
>
178
178
+
/{collection}
179
179
+
</Link>
180
180
+
/{rkey}
181
181
+
</div>
153
182
<div className="border-b" />
154
183
<Tabs defaultValue={View ? "view" : "json"} className="w-full">
155
184
<TabsList>
156
156
-
{View && <TabsTrigger value="view">View</TabsTrigger>}
157
157
-
<TabsTrigger value="json">JSON</TabsTrigger>
158
158
-
<TabsTrigger value="text">JSON (Text)</TabsTrigger>
185
185
+
{View && (
186
186
+
<TabsTrigger
187
187
+
value="view"
188
188
+
className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300"
189
189
+
>
190
190
+
View
191
191
+
</TabsTrigger>
192
192
+
)}
193
193
+
<TabsTrigger
194
194
+
value="json"
195
195
+
className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300"
196
196
+
>
197
197
+
JSON
198
198
+
</TabsTrigger>
199
199
+
<TabsTrigger
200
200
+
value="text"
201
201
+
className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300"
202
202
+
>
203
203
+
JSON (Text)
204
204
+
</TabsTrigger>
159
205
</TabsList>
160
206
{View && (
161
207
<TabsContent value="view" className="w-full overflow-x-auto">
+1
-1
src/routes/at:/$handle/$collection.index.lazy.tsx
···
116
116
}
117
117
118
118
if ((isLoading && !cursor) || !records) {
119
119
-
return <Loader className="min-h-screen" />;
119
119
+
return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />;
120
120
}
121
121
122
122
return (
+1
-1
src/routes/counter.lazy.tsx
···
207
207
} = stats;
208
208
209
209
return (
210
210
-
<div className="container mx-auto max-w-screen h-screen">
210
210
+
<div className="container mx-auto max-w-screen max-h-[calc(100vh-5rem)] h-screen">
211
211
<ParticlesComponent
212
212
isAnimating={isConfettiActive}
213
213
setIsAnimating={setIsConfettiActive}
+85
-27
src/routes/index.lazy.tsx
···
2
2
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
3
3
import { createLazyFileRoute, Link } from "@tanstack/react-router";
4
4
import { AtSign, Star } from "lucide-react";
5
5
+
import { useMemo } from "react";
6
6
+
7
7
+
const examples = [
8
8
+
<Link
9
9
+
key="danabra"
10
10
+
to="/at:/$handle"
11
11
+
params={{ handle: "danabra.mov" }}
12
12
+
className="text-blue-500"
13
13
+
>
14
14
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
15
15
+
at://danabra.mov
16
16
+
</div>
17
17
+
</Link>,
18
18
+
<Link
19
19
+
key="kot-posts"
20
20
+
to="/at:/$handle/$collection"
21
21
+
params={{ handle: "kot.pink", collection: "app.bsky.feed.post" }}
22
22
+
className="text-blue-500"
23
23
+
>
24
24
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
25
25
+
at://kot.pink/app.bsky.feed.post
26
26
+
</div>
27
27
+
</Link>,
28
28
+
<Link
29
29
+
key="robk"
30
30
+
to="/at:/$handle"
31
31
+
params={{ handle: "komaniecki.bsky.social" }}
32
32
+
className="text-blue-500"
33
33
+
>
34
34
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
35
35
+
at://komaniecki.bsky.social
36
36
+
</div>
37
37
+
</Link>,
38
38
+
<Link
39
39
+
key="why-generator"
40
40
+
to="/at:/$handle/$collection"
41
41
+
params={{ handle: "why.bsky.team", collection: "app.bsky.feed.generator" }}
42
42
+
className="text-blue-500"
43
43
+
>
44
44
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
45
45
+
at://why.bsky.team/app.bsky.feed.generator
46
46
+
</div>
47
47
+
</Link>,
48
48
+
<Link
49
49
+
key="jay"
50
50
+
to="/at:/$handle"
51
51
+
params={{ handle: "jay.bsky.social" }}
52
52
+
className="text-blue-500"
53
53
+
>
54
54
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
55
55
+
at://jay.bsky.social
56
56
+
</div>
57
57
+
</Link>,
58
58
+
<Link
59
59
+
key="nobody-knows"
60
60
+
to="/at:/$handle"
61
61
+
params={{ handle: "pippy.bsky.social" }}
62
62
+
className="text-blue-500"
63
63
+
>
64
64
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
65
65
+
at://pippy.bsky.social
66
66
+
</div>
67
67
+
</Link>,
68
68
+
<Link
69
69
+
key="jay"
70
70
+
to="/at:/$handle/$collection"
71
71
+
params={{ handle: "ngerakines.me", collection: "blue.badge.collection" }}
72
72
+
className="text-blue-500"
73
73
+
>
74
74
+
<div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors">
75
75
+
at://ngerakines.me/blue.badge.collection
76
76
+
</div>
77
77
+
</Link>,
78
78
+
];
5
79
6
80
export const Route = createLazyFileRoute("/")({
7
81
component: Index,
···
9
83
10
84
export default function Index() {
11
85
useDocumentTitle("atp.tools");
86
86
+
87
87
+
const randomExamples = useMemo(() => {
88
88
+
return [...examples].sort(() => Math.random() - 0.5).slice(0, 2);
89
89
+
}, []);
90
90
+
12
91
return (
13
13
-
<main className="min-h-screen relative max-w-[100vw]">
92
92
+
<main className="h-screen relative max-h-[calc(100vh-5rem)]">
14
93
<div className="container mx-auto px-4 py-16">
15
94
<div className="flex flex-col items-center justify-center md:min-h-[80vh]">
16
95
{/* Header section */}
···
24
103
<div className="w-full max-w-xl mx-auto">
25
104
<SmartSearchBar />
26
105
</div>
27
27
-
<div className="flex justify-center items-center gap-x-2 mt-4">
28
28
-
<div className="flex flex-row gap-x-1 text-muted-foreground">
29
29
-
<Star /> Try:
106
106
+
<div className="flex flex-row items-center mt-6 gap-2 justify-center">
107
107
+
<div className="flex items-center gap-x-2 text-muted-foreground">
108
108
+
<Star className="h-4 w-4" /> Try:{" "}
30
109
</div>
31
31
-
<div className="flex flex-col gap-1 md:flex-row">
32
32
-
<Link
33
33
-
to="/at:/$handle"
34
34
-
params={{ handle: "danabra.mov" }}
35
35
-
className="text-blue-500"
36
36
-
>
37
37
-
<div className="bg-muted text-muted-foreground rounded-full px-2">
38
38
-
at://danabra.mov
39
39
-
</div>
40
40
-
</Link>
41
41
-
42
42
-
<Link
43
43
-
to="/at:/$handle/$collection"
44
44
-
params={{
45
45
-
handle: "kot.pink",
46
46
-
collection: "app.bsky.feed.post",
47
47
-
}}
48
48
-
className="text-blue-500"
49
49
-
>
50
50
-
<div className="bg-muted text-muted-foreground rounded-full px-2">
51
51
-
at://kot.pink/app.bsky.feed.post
52
52
-
</div>
53
53
-
</Link>
110
110
+
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm">
111
111
+
{randomExamples}
54
112
</div>
55
113
</div>
56
114
</div>