+24
.zed/settings.json
+24
.zed/settings.json
···
1
+
{
2
+
"languages": {
3
+
"TypeScript": {
4
+
"language_servers": [
5
+
"wakatime",
6
+
"deno",
7
+
"!typescript-language-server",
8
+
"!vtsls",
9
+
"!eslint"
10
+
],
11
+
"formatter": "language_server"
12
+
},
13
+
"TSX": {
14
+
"language_servers": [
15
+
"wakatime",
16
+
"deno",
17
+
"!typescript-language-server",
18
+
"!vtsls",
19
+
"!eslint"
20
+
],
21
+
"formatter": "language_server"
22
+
}
23
+
}
24
+
}
+4
-8
deno.json
+4
-8
deno.json
···
11
11
},
12
12
"lint": {
13
13
"rules": {
14
-
"tags": [
15
-
"fresh",
16
-
"recommended"
17
-
]
14
+
"tags": ["fresh", "recommended"]
18
15
}
19
16
},
20
-
"exclude": [
21
-
"**/_fresh/*"
22
-
],
17
+
"exclude": ["**/_fresh/*"],
23
18
"imports": {
24
19
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
25
20
"preact": "https://esm.sh/preact@10.22.0",
···
35
30
"jsx": "react-jsx",
36
31
"jsxImportSource": "preact"
37
32
},
38
-
"nodeModulesDir": true
33
+
"nodeModulesDir": "auto",
34
+
"unstable": ["kv"]
39
35
}
+3
-1
fresh.config.ts
+3
-1
fresh.config.ts
···
1
1
import { defineConfig } from "$fresh/server.ts";
2
2
import tailwind from "$fresh/plugins/tailwind.ts";
3
+
import session from "./plugins/session.ts";
4
+
import didJson from "./plugins/did.ts";
3
5
4
6
export default defineConfig({
5
-
plugins: [tailwind()],
7
+
plugins: [tailwind(), session, didJson],
6
8
});
+8
-4
fresh.gen.ts
+8
-4
fresh.gen.ts
···
4
4
5
5
import * as $_404 from "./routes/_404.tsx";
6
6
import * as $_app from "./routes/_app.tsx";
7
-
import * as $api_joke from "./routes/api/joke.ts";
8
-
import * as $greet_name_ from "./routes/greet/[name].tsx";
7
+
import * as $api_oauth_callback from "./routes/api/oauth/callback.ts";
8
+
import * as $api_oauth_initiate from "./routes/api/oauth/initiate.ts";
9
9
import * as $index from "./routes/index.tsx";
10
+
import * as $login_index from "./routes/login/index.tsx";
10
11
import * as $Counter from "./islands/Counter.tsx";
12
+
import * as $HandleInput from "./islands/HandleInput.tsx";
11
13
import type { Manifest } from "$fresh/server.ts";
12
14
13
15
const manifest = {
14
16
routes: {
15
17
"./routes/_404.tsx": $_404,
16
18
"./routes/_app.tsx": $_app,
17
-
"./routes/api/joke.ts": $api_joke,
18
-
"./routes/greet/[name].tsx": $greet_name_,
19
+
"./routes/api/oauth/callback.ts": $api_oauth_callback,
20
+
"./routes/api/oauth/initiate.ts": $api_oauth_initiate,
19
21
"./routes/index.tsx": $index,
22
+
"./routes/login/index.tsx": $login_index,
20
23
},
21
24
islands: {
22
25
"./islands/Counter.tsx": $Counter,
26
+
"./islands/HandleInput.tsx": $HandleInput,
23
27
},
24
28
baseUrl: import.meta.url,
25
29
} satisfies Manifest;
+111
islands/HandleInput.tsx
+111
islands/HandleInput.tsx
···
1
+
import { useState } from 'preact/hooks'
2
+
import { JSX } from 'preact'
3
+
4
+
export default function HandleInput() {
5
+
const [handle, setHandle] = useState('')
6
+
const [error, setError] = useState<string | null>(null)
7
+
const [isPending, setIsPending] = useState(false)
8
+
9
+
const handleSubmit = async (e: JSX.TargetedEvent<HTMLFormElement>) => {
10
+
e.preventDefault()
11
+
if (!handle.trim()) return
12
+
13
+
setError(null)
14
+
setIsPending(true)
15
+
16
+
try {
17
+
const response = await fetch('/api/oauth/initiate', {
18
+
method: 'POST',
19
+
headers: {
20
+
'Content-Type': 'application/json',
21
+
},
22
+
body: JSON.stringify({ handle }),
23
+
})
24
+
25
+
if (!response.ok) {
26
+
const errorText = await response.text()
27
+
throw new Error(errorText || 'Login failed')
28
+
}
29
+
30
+
const data = await response.json()
31
+
32
+
// Add a small delay before redirecting for better UX
33
+
await new Promise((resolve) => setTimeout(resolve, 500))
34
+
35
+
// Redirect to ATProto OAuth flow
36
+
globalThis.location.href = data.redirectUrl
37
+
} catch (err) {
38
+
const message = err instanceof Error ? err.message : 'Login failed'
39
+
setError(message)
40
+
} finally {
41
+
setIsPending(false)
42
+
}
43
+
}
44
+
45
+
return (
46
+
<form onSubmit={handleSubmit}>
47
+
{error && (
48
+
<div className="text-red-500 mb-4 p-2 bg-red-50 dark:bg-red-950 dark:bg-opacity-30 rounded-md">
49
+
{error}
50
+
</div>
51
+
)}
52
+
53
+
<div className="mb-4">
54
+
<label
55
+
htmlFor="handle"
56
+
className="block mb-2 text-gray-700 dark:text-gray-300"
57
+
>
58
+
Enter your Bluesky handle:
59
+
</label>
60
+
<input
61
+
id="handle"
62
+
type="text"
63
+
value={handle}
64
+
onInput={(e) => setHandle((e.target as HTMLInputElement).value)}
65
+
placeholder="example.bsky.social"
66
+
disabled={isPending}
67
+
className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors"
68
+
/>
69
+
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
70
+
You can also enter an AT Protocol PDS URL, i.e.{' '}
71
+
<span className="whitespace-nowrap">https://bsky.social</span>
72
+
</p>
73
+
</div>
74
+
75
+
<button
76
+
type="submit"
77
+
disabled={isPending}
78
+
className={`w-full px-4 py-2 rounded-md bg-blue-500 dark:bg-blue-600 text-white font-medium hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 relative ${
79
+
isPending ? 'opacity-90 cursor-not-allowed' : ''
80
+
}`}
81
+
>
82
+
<span className={isPending ? 'invisible' : ''}>Login</span>
83
+
{isPending && (
84
+
<span className="absolute inset-0 flex items-center justify-center">
85
+
<svg
86
+
className="animate-spin -ml-1 mr-2 h-5 w-5 text-white"
87
+
xmlns="http://www.w3.org/2000/svg"
88
+
fill="none"
89
+
viewBox="0 0 24 24"
90
+
>
91
+
<circle
92
+
className="opacity-25"
93
+
cx="12"
94
+
cy="12"
95
+
r="10"
96
+
stroke="currentColor"
97
+
strokeWidth="4"
98
+
></circle>
99
+
<path
100
+
className="opacity-75"
101
+
fill="currentColor"
102
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
103
+
></path>
104
+
</svg>
105
+
<span>Connecting...</span>
106
+
</span>
107
+
)}
108
+
</button>
109
+
</form>
110
+
)
111
+
}
+1
main.ts
+1
main.ts
+58
oauth/client.ts
+58
oauth/client.ts
···
1
+
import { AtprotoOAuthClient } from 'jsr:@bigmoves/atproto-oauth-client'
2
+
import { SignJWT, jwtVerify } from "npm:jose@5.9.6";
3
+
import { SessionStore, StateStore } from "./storage.ts";
4
+
5
+
// Create a secure key for JWT signing
6
+
const jwtKey = new TextEncoder().encode(
7
+
Deno.env.get("JWT_SECRET") || "secure-jwt-secret-for-oauth-dpop-tokens"
8
+
);
9
+
10
+
class CustomJoseKey {
11
+
async createJwt(payload: Record<string, unknown>) {
12
+
const jwt = await new SignJWT(payload)
13
+
.setProtectedHeader({ alg: "HS256" })
14
+
.setIssuedAt()
15
+
.setExpirationTime("1h")
16
+
.sign(jwtKey);
17
+
return jwt;
18
+
}
19
+
20
+
async verifyJwt(jwt: string) {
21
+
const { payload } = await jwtVerify(jwt, jwtKey);
22
+
return payload;
23
+
}
24
+
}
25
+
26
+
export const createClient = async (db: Deno.Kv) => {
27
+
if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) {
28
+
throw new Error("PUBLIC_URL is not set");
29
+
}
30
+
31
+
const publicUrl = Deno.env.get("PUBLIC_URL");
32
+
const url = publicUrl || `http://127.0.0.1:${Deno.env.get("VITE_PORT")}`;
33
+
const enc = encodeURIComponent;
34
+
35
+
return new AtprotoOAuthClient({
36
+
clientMetadata: {
37
+
client_name: "Statusphere React App",
38
+
client_id: publicUrl
39
+
? `${url}/oauth-client-metadata.json`
40
+
: `http://localhost?redirect_uri=${
41
+
enc(`${url}/api/oauth/callback`)
42
+
}&scope=${enc("atproto transition:generic")}`,
43
+
client_uri: url,
44
+
redirect_uris: [`${url}/api/oauth/callback`],
45
+
scope: "atproto transition:generic",
46
+
grant_types: ["authorization_code", "refresh_token"],
47
+
response_types: ["code"],
48
+
application_type: "web",
49
+
token_endpoint_auth_method: "none",
50
+
dpop_bound_access_tokens: true,
51
+
},
52
+
stateStore: new StateStore(db),
53
+
sessionStore: new SessionStore(db)
54
+
});
55
+
};
56
+
57
+
const kv = await Deno.openKv()
58
+
export const oauthClient = await createClient(kv)
+59
oauth/session.ts
+59
oauth/session.ts
···
1
+
import { Agent } from "npm:@atproto/api";
2
+
import { getIronSession, SessionOptions } from "npm:iron-session";
3
+
import { FreshContext } from "$fresh/server.ts";
4
+
import { oauthClient } from "./client.ts";
5
+
6
+
export interface Session {
7
+
did: string;
8
+
}
9
+
10
+
export interface State {
11
+
session?: Session;
12
+
sessionUser?: Agent;
13
+
}
14
+
15
+
const cookieSecret = Deno.env.get("COOKIE_SECRET")
16
+
17
+
const sessionOptions: SessionOptions = {
18
+
cookieName: "sid",
19
+
password: cookieSecret!,
20
+
cookieOptions: {
21
+
secure: Deno.env.get("NODE_ENV") === "production",
22
+
httpOnly: true,
23
+
sameSite: true,
24
+
path: "/",
25
+
// Don't set domain explicitly - let browser determine it
26
+
domain: undefined,
27
+
},
28
+
};
29
+
30
+
export async function getSessionAgent(
31
+
req: Request,
32
+
ctx: FreshContext,
33
+
) {
34
+
const session = await getIronSession<Session>(
35
+
req,
36
+
new Response(),
37
+
sessionOptions,
38
+
);
39
+
40
+
if (!session.did) {
41
+
return null;
42
+
}
43
+
44
+
try {
45
+
const oauthSession = await oauthClient.restore(session.did);
46
+
return oauthSession ? new Agent(oauthSession) : null;
47
+
} catch (err) {
48
+
const logger = ctx.state.logger as {
49
+
warn: (obj: Record<string, unknown>, msg: string) => void;
50
+
};
51
+
logger.warn({ err }, "oauth restore failed");
52
+
session.destroy();
53
+
return null;
54
+
}
55
+
}
56
+
57
+
export function getSession(req: Request) {
58
+
return getIronSession<Session>(req, new Response(), sessionOptions);
59
+
}
+34
oauth/storage.ts
+34
oauth/storage.ts
···
1
+
import type {
2
+
NodeSavedSession,
3
+
NodeSavedSessionStore,
4
+
NodeSavedState,
5
+
NodeSavedStateStore,
6
+
} from "jsr:@bigmoves/atproto-oauth-client";
7
+
8
+
export class StateStore implements NodeSavedStateStore {
9
+
constructor(private db: Deno.Kv) {}
10
+
async get(key: string): Promise<NodeSavedState | undefined> {
11
+
const result = await this.db.get<NodeSavedState>(["auth_state", key]);
12
+
return result.value ?? undefined;
13
+
}
14
+
async set(key: string, val: NodeSavedState) {
15
+
await this.db.set(["auth_state", key], val);
16
+
}
17
+
async del(key: string) {
18
+
await this.db.delete(["auth_state", key]);
19
+
}
20
+
}
21
+
22
+
export class SessionStore implements NodeSavedSessionStore {
23
+
constructor(private db: Deno.Kv) {}
24
+
async get(key: string): Promise<NodeSavedSession | undefined> {
25
+
const result = await this.db.get<NodeSavedSession>(["auth_session", key]);
26
+
return result.value ?? undefined;
27
+
}
28
+
async set(key: string, val: NodeSavedSession) {
29
+
await this.db.set(["auth_session", key], val);
30
+
}
31
+
async del(key: string) {
32
+
await this.db.delete(["auth_session", key]);
33
+
}
34
+
}
+45
plugins/did.ts
+45
plugins/did.ts
···
1
+
import { FreshContext, Plugin } from "$fresh/server.ts";
2
+
import { Secp256k1Keypair, formatMultikey } from 'npm:@atproto/crypto'
3
+
4
+
export default {
5
+
name: 'did-json',
6
+
routes: [
7
+
{
8
+
path: '/.well-known/did.json',
9
+
handler: async () => {
10
+
const domain = Deno.env.get("PUBLIC_URL")?.split('://')[1] || 'localhost'
11
+
const privateKey = Deno.env.get("APPVIEW_K256_PRIVATE_KEY_HEX")
12
+
if (!privateKey) {
13
+
throw new Error("APPVIEW_K256_PRIVATE_KEY_HEX environment variable is required")
14
+
}
15
+
const keypair = await Secp256k1Keypair.import(privateKey)
16
+
const multikey = formatMultikey(keypair.jwtAlg, keypair.publicKeyBytes())
17
+
18
+
return Response.json({
19
+
'@context': ['https://www.w3.org/ns/did/v1'],
20
+
id: `did:web:${domain}`,
21
+
verificationMethod: [
22
+
{
23
+
id: `did:web:${domain}#atproto`,
24
+
type: 'Multikey',
25
+
controller: `did:web:${domain}`,
26
+
publicKeyMultibase: multikey,
27
+
},
28
+
],
29
+
service: [
30
+
{
31
+
id: '#swsh_appview',
32
+
type: 'SwshAppView',
33
+
serviceEndpoint: `https://${domain}`,
34
+
},
35
+
{
36
+
id: '#atproto_pds',
37
+
type: 'AtprotoPersonalDataServer',
38
+
serviceEndpoint: `https://${domain}`,
39
+
},
40
+
],
41
+
})
42
+
}
43
+
}
44
+
]
45
+
} as Plugin;
+24
plugins/session.ts
+24
plugins/session.ts
···
1
+
import { getSessionAgent } from "../oauth/session.ts"
2
+
import { FreshContext, Plugin } from "$fresh/server.ts";
3
+
import { oauthClient } from "../oauth/client.ts";
4
+
5
+
const plugin: Plugin = {
6
+
name: "session",
7
+
routes: [],
8
+
middlewares: [{
9
+
path: "/",
10
+
middleware: {
11
+
handler: async (req: Request, ctx: FreshContext) => {
12
+
const res = await ctx.next();
13
+
if (!oauthClient) {
14
+
console.warn("Missing required oauthClient in state");
15
+
return res;
16
+
}
17
+
const agent = await getSessionAgent(req, ctx);
18
+
return res;
19
+
},
20
+
},
21
+
}],
22
+
};
23
+
24
+
export default plugin;
+1
-1
routes/_404.tsx
+1
-1
routes/_404.tsx
-21
routes/api/joke.ts
-21
routes/api/joke.ts
···
1
-
import { FreshContext } from "$fresh/server.ts";
2
-
3
-
// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
4
-
const JOKES = [
5
-
"Why do Java developers often wear glasses? They can't C#.",
6
-
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
7
-
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
8
-
"I love pressing the F5 key. It's refreshing.",
9
-
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
10
-
"There are 10 types of people in the world. Those who understand binary and those who don't.",
11
-
"Why are assembly programmers often wet? They work below C level.",
12
-
"My favourite computer based band is the Black IPs.",
13
-
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
14
-
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
15
-
];
16
-
17
-
export const handler = (_req: Request, _ctx: FreshContext): Response => {
18
-
const randomIndex = Math.floor(Math.random() * JOKES.length);
19
-
const body = JOKES[randomIndex];
20
-
return new Response(body);
21
-
};
+37
routes/api/oauth/callback.ts
+37
routes/api/oauth/callback.ts
···
1
+
import { Handlers } from "$fresh/server.ts"
2
+
import { oauthClient } from "../../../oauth/client.ts";
3
+
import { getSession } from "../../../oauth/session.ts"
4
+
5
+
export const handler: Handlers = {
6
+
async GET(_req) {
7
+
const params = new URLSearchParams(_req.url.split("?")[1]);
8
+
const url = new URL(_req.url);
9
+
10
+
try {
11
+
const { session } = await oauthClient.callback(params);
12
+
// Use the common session options
13
+
const clientSession = await getSession(_req);
14
+
15
+
// Set the DID on the session
16
+
clientSession.did = session.did;
17
+
await clientSession.save();
18
+
19
+
// Get the origin and determine appropriate redirect
20
+
const host = params.get("host");
21
+
const protocol = url.protocol || "http";
22
+
const baseUrl = `${protocol}://${host}`;
23
+
24
+
console.info(
25
+
`OAuth callback successful, redirecting to ${baseUrl}/oauth-callback`,
26
+
);
27
+
28
+
// Redirect to the frontend oauth-callback page
29
+
30
+
return Response.redirect("/login/callback");
31
+
} catch (err) {
32
+
console.error({ err }, "oauth callback failed");
33
+
34
+
return Response.redirect("/oauth-callback?error=auth");
35
+
}
36
+
}
37
+
}
+37
routes/api/oauth/initiate.ts
+37
routes/api/oauth/initiate.ts
···
1
+
import type { Handlers } from "$fresh/server.ts";
2
+
import { isValidHandle } from 'npm:@atproto/syntax'
3
+
import { oauthClient } from "../../../oauth/client.ts";
4
+
5
+
function isValidUrl(url: string): boolean {
6
+
try {
7
+
const urlp = new URL(url)
8
+
// http or https
9
+
return urlp.protocol === 'http:' || urlp.protocol === 'https:'
10
+
} catch {
11
+
return false
12
+
}
13
+
}
14
+
15
+
export const handler: Handlers = {
16
+
async POST(_req) {
17
+
const data = await _req.json()
18
+
const handle = data.handle
19
+
if (
20
+
typeof handle !== 'string' ||
21
+
!(isValidHandle(handle) || isValidUrl(handle))
22
+
) {
23
+
return new Response("Invalid Handle", {status: 400})
24
+
}
25
+
26
+
// Initiate the OAuth flow
27
+
try {
28
+
const url = await oauthClient.authorize(handle, {
29
+
scope: 'atproto transition:generic',
30
+
})
31
+
return Response.json({ redirectUrl: url.toString() })
32
+
} catch (err) {
33
+
console.error({ err }, 'oauth authorize failed')
34
+
return new Response("Couldn't initiate login", {status: 500})
35
+
}
36
+
},
37
+
};
-5
routes/greet/[name].tsx
-5
routes/greet/[name].tsx
+1
-1
routes/index.tsx
+1
-1
routes/index.tsx
+53
routes/login/index.tsx
+53
routes/login/index.tsx
···
1
+
import { PageProps } from "$fresh/server.ts"
2
+
import { Head } from "$fresh/runtime.ts"
3
+
4
+
import HandleInput from "../../islands/HandleInput.tsx"
5
+
6
+
export async function submitHandle(handle: string) {
7
+
const response = await fetch('/api/oauth/initiate', {
8
+
method: 'POST',
9
+
headers: {
10
+
'Content-Type': 'application/json',
11
+
},
12
+
body: JSON.stringify({ handle }),
13
+
})
14
+
15
+
if (!response.ok) {
16
+
const errorText = await response.text()
17
+
throw new Error(errorText || 'Login failed')
18
+
}
19
+
20
+
const data = await response.json()
21
+
22
+
// Add a small delay before redirecting for better UX
23
+
await new Promise((resolve) => setTimeout(resolve, 500))
24
+
25
+
// Redirect to ATProto OAuth flow
26
+
globalThis.location.href = data.redirectUrl
27
+
}
28
+
29
+
export default function Login(_props: PageProps) {
30
+
return (
31
+
<>
32
+
<Head>
33
+
<title>Login - Airport</title>
34
+
</Head>
35
+
<div className="flex flex-col gap-8">
36
+
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm max-w-md mx-auto w-full">
37
+
<h2 className="text-xl font-semibold mb-4">Login with ATProto</h2>
38
+
39
+
<HandleInput />
40
+
41
+
<div className="mt-4 text-center">
42
+
<a
43
+
href="/"
44
+
className="text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
45
+
>
46
+
Cancel
47
+
</a>
48
+
</div>
49
+
</div>
50
+
</div>
51
+
</>
52
+
)
53
+
}
+63
-1
static/styles.css
+63
-1
static/styles.css
···
1
+
@import url("https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap");
2
+
1
3
@tailwind base;
2
4
@tailwind components;
3
-
@tailwind utilities;
5
+
@tailwind utilities;
6
+
7
+
@keyframes fadeOut {
8
+
0% {
9
+
opacity: 1;
10
+
}
11
+
75% {
12
+
opacity: 1;
13
+
} /* Hold full opacity for most of the animation */
14
+
100% {
15
+
opacity: 0;
16
+
}
17
+
}
18
+
19
+
.status-message-fade {
20
+
animation: fadeOut 2s forwards;
21
+
}
22
+
23
+
.font-spectral {
24
+
font-family: "Spectral", serif;
25
+
}
26
+
27
+
.grow-wrap {
28
+
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
29
+
display: grid;
30
+
}
31
+
.grow-wrap::after {
32
+
/* Note the weird space! Needed to preventy jumpy behavior */
33
+
content: attr(data-replicated-value) " ";
34
+
35
+
/* This is how textarea text behaves */
36
+
white-space: pre-wrap;
37
+
38
+
/* Hidden from view, clicks, and screen readers */
39
+
visibility: hidden;
40
+
}
41
+
.grow-wrap > textarea {
42
+
/* You could leave this, but after a user resizes, then it ruins the auto sizing */
43
+
resize: none;
44
+
45
+
/* Firefox shows scrollbar on growth, you can hide like this. */
46
+
overflow: hidden;
47
+
}
48
+
.grow-wrap > textarea,
49
+
.grow-wrap::after {
50
+
/* Identical styling required!! */
51
+
font: inherit;
52
+
53
+
/* Place on top of each other */
54
+
grid-area: 1 / 1 / 2 / 2;
55
+
}
56
+
57
+
/* Base styling */
58
+
@layer base {
59
+
body {
60
+
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
61
+
}
62
+
button {
63
+
@apply rounded-xl;
64
+
}
65
+
}
+29
types.ts
+29
types.ts
···
1
+
import { OAuthClient } from 'jsr:@bigmoves/atproto-oauth-client'
2
+
import { Agent } from 'npm:@atproto/api'
3
+
4
+
export interface AppContext {
5
+
oauthClient: OAuthClient
6
+
logger: {
7
+
warn: (obj: Record<string, unknown>, msg: string) => void
8
+
}
9
+
}
10
+
11
+
export interface Env {
12
+
COOKIE_SECRET: string
13
+
NODE_ENV: string
14
+
}
15
+
16
+
declare global {
17
+
const env: Env
18
+
}
19
+
20
+
// Extend Fresh's State interface
21
+
declare module "$fresh/server.ts" {
22
+
interface State {
23
+
oauthClient: OAuthClient
24
+
logger: {
25
+
warn: (obj: Record<string, unknown>, msg: string) => void
26
+
}
27
+
agent?: Agent | null
28
+
}
29
+
}