oops hyperfixated on popovers. anyway, nicknames are available

+3
bun.lock
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@fujocoded/authproto": "^0.0.4",
"astro": "^5.13.5",
"nanoid": "^5.1.5",
},
···
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.22", "", { "os": "linux", "cpu": "x64" }, "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg=="],
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.22", "", { "os": "win32", "cpu": "x64" }, "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA=="],
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@fujocoded/authproto": "^0.0.4",
+
"@lucide/astro": "^0.542.0",
"astro": "^5.13.5",
"nanoid": "^5.1.5",
},
···
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.22", "", { "os": "linux", "cpu": "x64" }, "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg=="],
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.22", "", { "os": "win32", "cpu": "x64" }, "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA=="],
+
+
"@lucide/astro": ["@lucide/astro@0.542.0", "", { "peerDependencies": { "astro": "^4 || ^5" } }, "sha512-W1WcrLm4iZgjy40fhkAX8EW5LA4mGK23pewBixfRYhMj/J00Tba3GgHOEls5JqAsvlwQc6ClOLYSYA3Spzzb7w=="],
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
+1
db/config.ts
···
const Users = defineTable({
columns: {
id: column.number({ primaryKey: true }),
userDid: column.text({ name: "user_did", unique: true }),
joinedAt: column.date({ name: "joined_at", default: NOW }),
},
···
const Users = defineTable({
columns: {
id: column.number({ primaryKey: true }),
+
nickname: column.text({ unique: true, optional: true }),
userDid: column.text({ name: "user_did", unique: true }),
joinedAt: column.date({ name: "joined_at", default: NOW }),
},
+1
package.json
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@fujocoded/authproto": "^0.0.4",
"astro": "^5.13.5",
"nanoid": "^5.1.5"
},
···
"@astrojs/db": "^0.17.1",
"@astrojs/node": "^9.4.3",
"@fujocoded/authproto": "^0.0.4",
+
"@lucide/astro": "^0.542.0",
"astro": "^5.13.5",
"nanoid": "^5.1.5"
},
+44 -5
src/actions/users.ts
···
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro:content";
-
import { db, Users } from "astro:db";
export const usersActions = {
addUser: defineAction({
accept: "form",
input: z.object({
-
did: z.string(),
}),
-
handler: async ({ did }, context) => {
const loggedInUser = context.locals.loggedInUser;
if (!loggedInUser) {
···
const user = await db
.insert(Users)
-
.values({ userDid: did })
.returning();
return user;
},
-
})
}
···
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro:content";
+
import { db, eq, Users } from "astro:db";
export const usersActions = {
addUser: defineAction({
accept: "form",
input: z.object({
+
nickname: z.string(),
}),
+
handler: async (input, context) => {
const loggedInUser = context.locals.loggedInUser;
if (!loggedInUser) {
···
const user = await db
.insert(Users)
+
.values({
+
...input.nickname && { nickname: input.nickname },
+
userDid: loggedInUser.did,
+
})
.returning();
return user;
},
+
}),
+
editUser: defineAction({
+
accept: "form",
+
input: z.object({
+
nickname: z.string().nonempty({ message: "Don't submit an empty nickname!" }),
+
}),
+
handler: async ({ nickname }, context) => {
+
const loggedInUser = context.locals.loggedInUser;
+
+
if (!loggedInUser) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You need to be logged in to set a nickname!",
+
});
+
}
+
+
// check if the user exists
+
const user = await db.select()
+
.from(Users)
+
.where(eq(Users.userDid, loggedInUser.did))
+
.limit(1);
+
+
if (user.length === 0) {
+
throw new ActionError({
+
code: "NOT_FOUND",
+
message: "Either you haven't connected your PDS account or something went wrong.",
+
});
+
}
+
+
const updatedUser = await db.update(Users)
+
.set({ nickname })
+
.where(eq(Users.userDid, loggedInUser.did))
+
.returning();
+
+
return updatedUser;
+
},
+
}),
}
+20 -10
src/actions/works.ts
···
accept: "form",
input: workSchema,
handler: async (input, context) => {
// check against auth
-
if (!context.locals.loggedInUser) {
throw new ActionError({
code: "UNAUTHORIZED",
message: "You're not logged in!",
});
}
-
// const agent = await
-
-
// find the id of the logged in user
-
const userId = await db
.select({ did: Users.userDid })
.from(Users)
-
.where(
-
eq(Users.userDid, context.locals.loggedInUser.did)
-
);
-
// check nanoid for collision probability: https://zelark.github.io/nano-id-cc/
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(alphabet, 16);
···
const work = await db.insert(Works).values({
slug,
-
author: userId[0].did,
title: input.title,
content: input.content,
tags: input.tags,
}).returning();
return work;
},
···
accept: "form",
input: workSchema,
handler: async (input, context) => {
+
const loggedInUser = context.locals.loggedInUser;
+
// check against auth
+
if (!loggedInUser) {
throw new ActionError({
code: "UNAUTHORIZED",
message: "You're not logged in!",
});
}
+
// find the did of the logged in user
+
const query = await db
.select({ did: Users.userDid })
.from(Users)
+
.where(eq(Users.userDid, loggedInUser.did))
+
.limit(1);
+
+
if (query.length === 0) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You can only add a work if you connected your PDS!",
+
});
+
}
+
+
const user = query[0];
// check nanoid for collision probability: https://zelark.github.io/nano-id-cc/
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(alphabet, 16);
···
const work = await db.insert(Works).values({
slug,
+
author: user.did,
title: input.title,
content: input.content,
tags: input.tags,
}).returning();
+
+
// depending on whether someone toggled the privacy option, push this into firehouse
+
// const agent = await
return work;
},
+39 -2
src/components/Dialog.astro
···
---
interface Props {
id: string;
title: string;
···
<header>
<h1>{title}</h1>
<form method="dialog">
-
<button>close</button>
</form>
</header>
-
<slot />
</dialog>
···
---
+
import X from "@lucide/astro/icons/x";
+
interface Props {
id: string;
title: string;
···
<header>
<h1>{title}</h1>
<form method="dialog">
+
<button aria-label="close" class="close">
+
<X />
+
</button>
</form>
</header>
+
<div class="dialog-content">
+
<slot />
+
</div>
</dialog>
+
<style>
+
dialog {
+
margin: auto;
+
min-height: 200px;
+
padding: 0;
+
+
header {
+
display: flex;
+
align-items: center;
+
background-color: aqua;
+
padding: 0.5rem;
+
+
h1 {
+
flex: 1;
+
font-size: var(--step--1);
+
}
+
+
.close {
+
cursor: pointer;
+
min-width: 44px;
+
min-height: 44px;
+
display: grid;
+
place-content: center;
+
}
+
}
+
+
.dialog-content {
+
padding: 1rem;
+
}
+
}
+
</style>
+6 -2
src/components/Navbar.astro
···
const LINKS = [
{ label: "Home", url: "/" },
{ label: "Works", url: "/works" },
-
{ label: "Login", url: "/login" },
-
{ label: "Settings", url: "/user" },
];
---
<nav id="main-nav">
<ul>
{LINKS.map(({ label, url }) => (
<li><a href={url}>{label}</a></li>
))}
</ul>
</nav>
···
const LINKS = [
{ label: "Home", url: "/" },
{ label: "Works", url: "/works" },
];
+
+
const loggedInUser = Astro.locals.loggedInUser;
---
<nav id="main-nav">
<ul>
{LINKS.map(({ label, url }) => (
<li><a href={url}>{label}</a></li>
))}
+
{loggedInUser
+
? <li><a href="/user">Settings</a></li>
+
: <li><a href="/login">Login</a></li>
+
}
</ul>
</nav>
+49 -25
src/components/Popover.astro
···
---
interface Props {
label: string;
-
icon?: string;
title?: string;
class?: string;
}
-
const { label, icon, title, class: className, ...rest } = Astro.props;
---
-
<details class:list={["popup", className]} {...rest}>
-
<summary>
-
{icon
-
? <div class="icon" aria-label={label}>x</div>
-
: <span>{label}</span>
-
}
-
</summary>
-
{title &&
<h3>{title}</h3>
-
}
<slot />
-
</details>
-
<style>
-
.popup {
-
display: inline-block;
-
summary {
-
font-size: var(--step--2);
-
cursor: pointer;
-
}
-
&::details-content {
-
position: absolute;
-
z-index: 1;
-
}
-
}
-
</style>
···
---
+
import { Info, TriangleAlert, Skull } from "@lucide/astro";
+
interface Props {
+
id?: string;
label: string;
+
icon?: "info" | "warning" | "danger";
title?: string;
class?: string;
}
+
const { id, label, icon, title, class: className, ...rest } = Astro.props;
---
+
<!-- type button needs to be set here, otherwise it doesn't work inside forms -->
+
<button
+
type="button"
+
id={`${id}-trigger`}
+
class:list={["popup", "anchor", className]}
+
aria-describedby={id}
+
popovertarget={id}
+
>
+
{icon
+
?
+
<div class="icon" aria-label={label}>
+
{icon &&
+
(icon === "info") ? <Info /> :
+
(icon === "warning") ? <TriangleAlert /> :
+
(icon === "danger") ? <Skull /> :
+
<></>
+
}
+
</div>
+
: <span>{label}</span>
+
}
+
</button>
+
<div {id} class:list={["popup", className]} role="tooltip" {...rest} popover="auto">
+
{title && (
<h3>{title}</h3>
+
)}
<slot />
+
</div>
+
+
<style define:vars={{ trigger: `${id}-anchor` }}>
+
.popup.anchor {
+
anchor: var(--trigger);
+
}
+
div.popup {
+
position-anchor: var(--trigger);
+
inset: anchor(trigger );
+
position-try-fallbacks: flip-block, flip-inline;
+
}
+
</style>
+
<script define:vars={{ id }} is:inline>
+
const trigger = document.getElementById(`${id}-trigger`);
+
const popover = document.getElementById(id);
+
trigger.addEventListener("click", (e) => {
+
e.preventDefault();
+
popover.togglePopover();
+
});
+
</script>
+7
src/pages/index.astro
···
---
import Layout from "../layouts/Layout.astro";
const currentUser = Astro.locals.loggedInUser;
···
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Praesentium eum est quisquam distinctio magni recusandae quia vero tempore consectetur! Dolore repellat, voluptatem dignissimos sit eaque iste atque facilis in saepe?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorem, maxime libero eveniet repellat corporis, architecto voluptate maiores ullam accusamus quasi nostrum nihil placeat cum earum ex voluptatum, harum sunt quam!</p>
</main>
{currentUser
? <>
···
---
+
import Popover from "~/Popover.astro";
import Layout from "../layouts/Layout.astro";
const currentUser = Astro.locals.loggedInUser;
···
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Praesentium eum est quisquam distinctio magni recusandae quia vero tempore consectetur! Dolore repellat, voluptatem dignissimos sit eaque iste atque facilis in saepe?</p>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorem, maxime libero eveniet repellat corporis, architecto voluptate maiores ullam accusamus quasi nostrum nihil placeat cum earum ex voluptatum, harum sunt quam!</p>
</main>
+
+
<form>
+
<Popover id="hey" label="test">
+
<p>hello?</p>
+
</Popover>
+
</form>
{currentUser
? <>
+6 -7
src/pages/login.astro
···
) : (
// If there's no current user, show the log in button
<form action="/oauth/login" method="post">
-
<label for="handle">Input your handle
-
<Popover label="help">
-
<h3>What's my handle?</h3>
-
<p>It'll look like a website URL without the <samp>https://</samp> or slashes, so a typical BlueSky handle will look something like: <b>alice.bsky.social</b>.</p>
-
<p>What yours will look like depends on whether you made a custom handle!</p>
-
</Popover>
-
</label>
<input name="atproto-id" id="handle" required />
<button type="submit">Login</button>
</form>
···
) : (
// If there's no current user, show the log in button
<form action="/oauth/login" method="post">
+
<label for="handle">Input your handle</label>
+
<Popover id="handle-help" label="help">
+
<h3>What's my handle?</h3>
+
<p>It'll look like a website URL without the <samp>https://</samp> or slashes, so a typical BlueSky handle will look something like: <b>alice.bsky.social</b>.</p>
+
<p>What yours will look like depends on whether you made a custom handle!</p>
+
</Popover>
<input name="atproto-id" id="handle" required />
<button type="submit">Login</button>
</form>
+11 -3
src/pages/user/index.astro
···
import { actions } from "astro:actions";
import { db, eq, Users, Works } from "astro:db";
import Dialog from "~/Dialog.astro";
const loggedInUser = Astro.locals.loggedInUser;
···
<p>{loggedInUser?.handle}</p>
<!-- registration will only happen in the below form! -->
-
{!user && (
<>
<h2>Connect account</h2>
<div class="info">
···
<Dialog id="connect-account" title="Are you sure?">
<form action={actions.usersActions.addUser} method="post">
-
<input type="hidden" name="did" value={loggedInUser.did} />
-
<button formmethod="dialog">Cancel</button>
<button>Confirm</button>
</form>
···
import { actions } from "astro:actions";
import { db, eq, Users, Works } from "astro:db";
import Dialog from "~/Dialog.astro";
+
import Popover from "~/Popover.astro";
const loggedInUser = Astro.locals.loggedInUser;
···
<p>{loggedInUser?.handle}</p>
<!-- registration will only happen in the below form! -->
+
{(user.length === 0) && (
<>
<h2>Connect account</h2>
<div class="info">
···
<Dialog id="connect-account" title="Are you sure?">
<form action={actions.usersActions.addUser} method="post">
+
<label for="nickname">Nickname</label>
+
<Popover id="nickname-info" label="info" icon="danger">
+
<p>You can optionally set your nickname for this site. This is separate from your handle and acts as your identifier.</p>
+
<p>Think of your handle as what you use to log in with, and your nickname as the name you want to publish your works under.</p>
+
<h3>Important</h3>
+
<p>If you do set a nickname, </p>
+
</Popover>
+
<input type="text" name="nickname" id="nickname" />
+
<button formmethod="dialog">Cancel</button>
<button>Confirm</button>
</form>
+1 -1
src/pages/works/[id].astro
···
{(Works.tags as Tag[]).map(tag => (
<a href={tag.url}>{tag.label}</a>
))}
-
<Fragment set:html={Works.content} />
</>
))}
···
{(Works.tags as Tag[]).map(tag => (
<a href={tag.url}>{tag.label}</a>
))}
+
<Fragment set:html={Works.content} />
</>
))}