experimenting with making decentralized fanfic archives on atproto. github mirror: https://github.com/haetae-bit/fanfic-atproto

oops hyperfixated on popovers. anyway, nicknames are available

+3
bun.lock
··· 7 7 "@astrojs/db": "^0.17.1", 8 8 "@astrojs/node": "^9.4.3", 9 9 "@fujocoded/authproto": "^0.0.4", 10 + "@lucide/astro": "^0.542.0", 10 11 "astro": "^5.13.5", 11 12 "nanoid": "^5.1.5", 12 13 }, ··· 204 205 "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.22", "", { "os": "linux", "cpu": "x64" }, "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg=="], 205 206 206 207 "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.22", "", { "os": "win32", "cpu": "x64" }, "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA=="], 208 + 209 + "@lucide/astro": ["@lucide/astro@0.542.0", "", { "peerDependencies": { "astro": "^4 || ^5" } }, "sha512-W1WcrLm4iZgjy40fhkAX8EW5LA4mGK23pewBixfRYhMj/J00Tba3GgHOEls5JqAsvlwQc6ClOLYSYA3Spzzb7w=="], 207 210 208 211 "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], 209 212
+1
db/config.ts
··· 3 3 const Users = defineTable({ 4 4 columns: { 5 5 id: column.number({ primaryKey: true }), 6 + nickname: column.text({ unique: true, optional: true }), 6 7 userDid: column.text({ name: "user_did", unique: true }), 7 8 joinedAt: column.date({ name: "joined_at", default: NOW }), 8 9 },
+1
package.json
··· 5 5 "@astrojs/db": "^0.17.1", 6 6 "@astrojs/node": "^9.4.3", 7 7 "@fujocoded/authproto": "^0.0.4", 8 + "@lucide/astro": "^0.542.0", 8 9 "astro": "^5.13.5", 9 10 "nanoid": "^5.1.5" 10 11 },
+44 -5
src/actions/users.ts
··· 1 1 import { ActionError, defineAction } from "astro:actions"; 2 2 import { z } from "astro:content"; 3 - import { db, Users } from "astro:db"; 3 + import { db, eq, Users } from "astro:db"; 4 4 5 5 export const usersActions = { 6 6 addUser: defineAction({ 7 7 accept: "form", 8 8 input: z.object({ 9 - did: z.string(), 9 + nickname: z.string(), 10 10 }), 11 - handler: async ({ did }, context) => { 11 + handler: async (input, context) => { 12 12 const loggedInUser = context.locals.loggedInUser; 13 13 14 14 if (!loggedInUser) { ··· 20 20 21 21 const user = await db 22 22 .insert(Users) 23 - .values({ userDid: did }) 23 + .values({ 24 + ...input.nickname && { nickname: input.nickname }, 25 + userDid: loggedInUser.did, 26 + }) 24 27 .returning(); 25 28 26 29 return user; 27 30 }, 28 - }) 31 + }), 32 + editUser: defineAction({ 33 + accept: "form", 34 + input: z.object({ 35 + nickname: z.string().nonempty({ message: "Don't submit an empty nickname!" }), 36 + }), 37 + handler: async ({ nickname }, context) => { 38 + const loggedInUser = context.locals.loggedInUser; 39 + 40 + if (!loggedInUser) { 41 + throw new ActionError({ 42 + code: "UNAUTHORIZED", 43 + message: "You need to be logged in to set a nickname!", 44 + }); 45 + } 46 + 47 + // check if the user exists 48 + const user = await db.select() 49 + .from(Users) 50 + .where(eq(Users.userDid, loggedInUser.did)) 51 + .limit(1); 52 + 53 + if (user.length === 0) { 54 + throw new ActionError({ 55 + code: "NOT_FOUND", 56 + message: "Either you haven't connected your PDS account or something went wrong.", 57 + }); 58 + } 59 + 60 + const updatedUser = await db.update(Users) 61 + .set({ nickname }) 62 + .where(eq(Users.userDid, loggedInUser.did)) 63 + .returning(); 64 + 65 + return updatedUser; 66 + }, 67 + }), 29 68 }
+20 -10
src/actions/works.ts
··· 19 19 accept: "form", 20 20 input: workSchema, 21 21 handler: async (input, context) => { 22 + const loggedInUser = context.locals.loggedInUser; 23 + 22 24 // check against auth 23 - if (!context.locals.loggedInUser) { 25 + if (!loggedInUser) { 24 26 throw new ActionError({ 25 27 code: "UNAUTHORIZED", 26 28 message: "You're not logged in!", 27 29 }); 28 30 } 29 31 30 - // const agent = await 31 - 32 - // find the id of the logged in user 33 - const userId = await db 32 + // find the did of the logged in user 33 + const query = await db 34 34 .select({ did: Users.userDid }) 35 35 .from(Users) 36 - .where( 37 - eq(Users.userDid, context.locals.loggedInUser.did) 38 - ); 39 - 36 + .where(eq(Users.userDid, loggedInUser.did)) 37 + .limit(1); 38 + 39 + if (query.length === 0) { 40 + throw new ActionError({ 41 + code: "UNAUTHORIZED", 42 + message: "You can only add a work if you connected your PDS!", 43 + }); 44 + } 45 + 46 + const user = query[0]; 40 47 // check nanoid for collision probability: https://zelark.github.io/nano-id-cc/ 41 48 const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 42 49 const nanoid = customAlphabet(alphabet, 16); ··· 44 51 45 52 const work = await db.insert(Works).values({ 46 53 slug, 47 - author: userId[0].did, 54 + author: user.did, 48 55 title: input.title, 49 56 content: input.content, 50 57 tags: input.tags, 51 58 }).returning(); 59 + 60 + // depending on whether someone toggled the privacy option, push this into firehouse 61 + // const agent = await 52 62 53 63 return work; 54 64 },
+39 -2
src/components/Dialog.astro
··· 1 1 --- 2 + import X from "@lucide/astro/icons/x"; 3 + 2 4 interface Props { 3 5 id: string; 4 6 title: string; ··· 11 13 <header> 12 14 <h1>{title}</h1> 13 15 <form method="dialog"> 14 - <button>close</button> 16 + <button aria-label="close" class="close"> 17 + <X /> 18 + </button> 15 19 </form> 16 20 </header> 17 21 18 - <slot /> 22 + <div class="dialog-content"> 23 + <slot /> 24 + </div> 19 25 </dialog> 20 26 27 + <style> 28 + dialog { 29 + margin: auto; 30 + min-height: 200px; 31 + padding: 0; 32 + 33 + header { 34 + display: flex; 35 + align-items: center; 36 + background-color: aqua; 37 + padding: 0.5rem; 38 + 39 + h1 { 40 + flex: 1; 41 + font-size: var(--step--1); 42 + } 43 + 44 + .close { 45 + cursor: pointer; 46 + min-width: 44px; 47 + min-height: 44px; 48 + display: grid; 49 + place-content: center; 50 + } 51 + } 52 + 53 + .dialog-content { 54 + padding: 1rem; 55 + } 56 + } 57 + </style>
+6 -2
src/components/Navbar.astro
··· 2 2 const LINKS = [ 3 3 { label: "Home", url: "/" }, 4 4 { label: "Works", url: "/works" }, 5 - { label: "Login", url: "/login" }, 6 - { label: "Settings", url: "/user" }, 7 5 ]; 6 + 7 + const loggedInUser = Astro.locals.loggedInUser; 8 8 --- 9 9 <nav id="main-nav"> 10 10 <ul> 11 11 {LINKS.map(({ label, url }) => ( 12 12 <li><a href={url}>{label}</a></li> 13 13 ))} 14 + {loggedInUser 15 + ? <li><a href="/user">Settings</a></li> 16 + : <li><a href="/login">Login</a></li> 17 + } 14 18 </ul> 15 19 </nav>
+49 -25
src/components/Popover.astro
··· 1 1 --- 2 + import { Info, TriangleAlert, Skull } from "@lucide/astro"; 3 + 2 4 interface Props { 5 + id?: string; 3 6 label: string; 4 - icon?: string; 7 + icon?: "info" | "warning" | "danger"; 5 8 title?: string; 6 9 class?: string; 7 10 } 8 11 9 - const { label, icon, title, class: className, ...rest } = Astro.props; 12 + const { id, label, icon, title, class: className, ...rest } = Astro.props; 10 13 --- 11 - <details class:list={["popup", className]} {...rest}> 12 - <summary> 13 - {icon 14 - ? <div class="icon" aria-label={label}>x</div> 15 - : <span>{label}</span> 16 - } 17 - </summary> 14 + <!-- type button needs to be set here, otherwise it doesn't work inside forms --> 15 + <button 16 + type="button" 17 + id={`${id}-trigger`} 18 + class:list={["popup", "anchor", className]} 19 + aria-describedby={id} 20 + popovertarget={id} 21 + > 22 + {icon 23 + ? 24 + <div class="icon" aria-label={label}> 25 + {icon && 26 + (icon === "info") ? <Info /> : 27 + (icon === "warning") ? <TriangleAlert /> : 28 + (icon === "danger") ? <Skull /> : 29 + <></> 30 + } 31 + </div> 32 + : <span>{label}</span> 33 + } 34 + </button> 18 35 19 - {title && 36 + <div {id} class:list={["popup", className]} role="tooltip" {...rest} popover="auto"> 37 + {title && ( 20 38 <h3>{title}</h3> 21 - } 39 + )} 22 40 23 41 <slot /> 24 - </details> 42 + </div> 43 + 44 + <style define:vars={{ trigger: `${id}-anchor` }}> 45 + .popup.anchor { 46 + anchor: var(--trigger); 47 + } 25 48 26 - <style> 27 - .popup { 28 - display: inline-block; 49 + div.popup { 50 + position-anchor: var(--trigger); 51 + inset: anchor(trigger ); 52 + position-try-fallbacks: flip-block, flip-inline; 53 + } 54 + </style> 29 55 30 - summary { 31 - font-size: var(--step--2); 32 - cursor: pointer; 33 - } 56 + <script define:vars={{ id }} is:inline> 57 + const trigger = document.getElementById(`${id}-trigger`); 58 + const popover = document.getElementById(id); 34 59 35 - &::details-content { 36 - position: absolute; 37 - z-index: 1; 38 - } 39 - } 40 - </style> 60 + trigger.addEventListener("click", (e) => { 61 + e.preventDefault(); 62 + popover.togglePopover(); 63 + }); 64 + </script>
+7
src/pages/index.astro
··· 1 1 --- 2 + import Popover from "~/Popover.astro"; 2 3 import Layout from "../layouts/Layout.astro"; 3 4 4 5 const currentUser = Astro.locals.loggedInUser; ··· 9 10 <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> 10 11 <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> 11 12 </main> 13 + 14 + <form> 15 + <Popover id="hey" label="test"> 16 + <p>hello?</p> 17 + </Popover> 18 + </form> 12 19 13 20 {currentUser 14 21 ? <>
+6 -7
src/pages/login.astro
··· 14 14 ) : ( 15 15 // If there's no current user, show the log in button 16 16 <form action="/oauth/login" method="post"> 17 - <label for="handle">Input your handle 18 - <Popover label="help"> 19 - <h3>What's my handle?</h3> 20 - <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> 21 - <p>What yours will look like depends on whether you made a custom handle!</p> 22 - </Popover> 23 - </label> 17 + <label for="handle">Input your handle</label> 18 + <Popover id="handle-help" label="help"> 19 + <h3>What's my handle?</h3> 20 + <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> 21 + <p>What yours will look like depends on whether you made a custom handle!</p> 22 + </Popover> 24 23 <input name="atproto-id" id="handle" required /> 25 24 <button type="submit">Login</button> 26 25 </form>
+11 -3
src/pages/user/index.astro
··· 3 3 import { actions } from "astro:actions"; 4 4 import { db, eq, Users, Works } from "astro:db"; 5 5 import Dialog from "~/Dialog.astro"; 6 + import Popover from "~/Popover.astro"; 6 7 7 8 const loggedInUser = Astro.locals.loggedInUser; 8 9 ··· 20 21 <p>{loggedInUser?.handle}</p> 21 22 22 23 <!-- registration will only happen in the below form! --> 23 - {!user && ( 24 + {(user.length === 0) && ( 24 25 <> 25 26 <h2>Connect account</h2> 26 27 <div class="info"> ··· 31 32 32 33 <Dialog id="connect-account" title="Are you sure?"> 33 34 <form action={actions.usersActions.addUser} method="post"> 34 - <input type="hidden" name="did" value={loggedInUser.did} /> 35 - 35 + <label for="nickname">Nickname</label> 36 + <Popover id="nickname-info" label="info" icon="danger"> 37 + <p>You can optionally set your nickname for this site. This is separate from your handle and acts as your identifier.</p> 38 + <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> 39 + <h3>Important</h3> 40 + <p>If you do set a nickname, </p> 41 + </Popover> 42 + <input type="text" name="nickname" id="nickname" /> 43 + 36 44 <button formmethod="dialog">Cancel</button> 37 45 <button>Confirm</button> 38 46 </form>
+1 -1
src/pages/works/[id].astro
··· 24 24 {(Works.tags as Tag[]).map(tag => ( 25 25 <a href={tag.url}>{tag.label}</a> 26 26 ))} 27 - 27 + 28 28 <Fragment set:html={Works.content} /> 29 29 </> 30 30 ))}