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

refine work + chapter structures

Changed files
+131 -132
db
lexicons
fan
fics
src
actions
works
components
chapters
lib
+9 -3
db/config.ts
··· 24 24 tags: column.json(), 25 25 createdAt: column.date({ name: "created_at", default: NOW }), 26 26 updatedAt: column.date({ name: "updated_at", optional: true }), 27 + draft: column.boolean({ default: true }), 27 28 }, 28 29 indexes: [ 29 30 { on: ["author", "slug"], unique: true }, 30 31 { on: ["slug", "createdAt"], unique: true }, 31 - { on: ["uri", "createdAt"], unique: true }, 32 32 ], 33 33 }); 34 34 ··· 37 37 id: column.number({ primaryKey: true }), 38 38 uri: column.text({ optional: true }), 39 39 workId: column.number({ references: () => Works.columns.id }), 40 - // order: column.number(), // i don't think this is needed... 40 + slug: column.text({ unique: true }), 41 41 title: column.text(), 42 + warnings: column.text({ multiline: true, optional: true }), 42 43 notes: column.text({ multiline: true, optional: true }), 43 - content: column.text({ multiline: true }), 44 + content: column.json(), 44 45 createdAt: column.date({ name: "created_at", default: NOW }), 45 46 updatedAt: column.date({ name: "updated_at", optional: true }), 47 + draft: column.boolean({ default: true }), 46 48 }, 49 + indexes: [ 50 + { on: ["workId", "slug"], unique: true }, 51 + { on: ["workId", "createdAt"], unique: true }, 52 + ] 47 53 }); 48 54 49 55 const Tags = defineTable({
+5 -5
db/seed.ts
··· 13 13 author: "test", 14 14 title: "Hey there title", 15 15 summary: "<p>i have evil html</p>", 16 - tags: [{ label: "test", url: "#" }], 16 + tags: [{ label: "test", slug: "#", type: "character" }], 17 17 }, 18 18 { 19 19 slug: "1235", 20 20 author: "another", 21 21 title: "Hello world", 22 22 summary: "<p>whoag i have <b>BOLD</b></p>", 23 - tags: [{ label: "label", url: "#" }], 23 + tags: [{ label: "label", slug: "#", type: "relationship" }, { label: "label", slug: "#", type: "character" }], 24 24 }, 25 25 { 26 26 uri: "at://did:plc:dg2qmmjic7mmecrbvpuhtvh6/moe.fanfics.works/3lyeiyq32ek2o", ··· 35 35 await db.insert(Chapters).values([ 36 36 { 37 37 workId: 1, 38 - // order: 1, 38 + slug: `${new Date().valueOf().toString()}-1`, 39 39 title: "chapter title 1", 40 40 content: "what's up?! <b>bold</b> and <em>italics</em> should work.", 41 41 }, 42 42 { 43 43 workId: 2, 44 - // order: 1, 44 + slug: `${new Date().valueOf().toString()}-2`, 45 45 title: "chapter title 2", 46 46 content: "test", 47 47 }, 48 48 { 49 49 workId: 3, 50 - // order: 1, 50 + slug: `${new Date().valueOf().toString()}-3`, 51 51 title: "at proto", 52 52 content: "what's up?! <b>bold</b> and <em>italics</em> should work.", 53 53 }
+7
lexicons/fan/fics/work.json
··· 43 43 "maxLength": 3000, 44 44 "maxGraphemes": 3000 45 45 }, 46 + "chapters": { 47 + "type": "array", 48 + "items": { 49 + "type": "ref", 50 + "ref": "fan.fics.work.chapter" 51 + } 52 + }, 46 53 "createdAt": { 47 54 "type": "string", 48 55 "format": "datetime"
+39 -115
src/actions/works/addWork.ts
··· 1 1 import { ActionError, defineAction } from "astro:actions"; 2 - import { db, eq, Users, Works } from "astro:db"; 2 + import { Chapters, db, eq, Users, Works } from "astro:db"; 3 3 import { z } from "astro:schema"; 4 - import { AtUri } from "@atproto/api"; 5 - import { TID } from "@atproto/common-web"; 6 - import { customAlphabet } from "nanoid"; 7 - import { callSlices, fetchBskyPost, fetchLeaflet, getAgent } from "@/lib/atproto"; 8 - import { addChapter, updateWork } from "@/lib/db"; 4 + import { customAlphabet, nanoid } from "nanoid"; 5 + import { createFanficWork, importChapter } from "@/lib/atproto"; 9 6 import schema from "./schema"; 7 + import type { BskyPost, ChapterText, LeafletDoc } from "@/lib/types"; 10 8 11 9 export default defineAction({ 12 10 accept: "form", 13 - input: schema.extend({ 14 - option: z.enum(["manual", "bsky", "leaflet"]), 15 - bskyUri: z.string().optional(), 16 - leafletUri: z.string().optional(), 17 - chapterTitle: z.string().optional(), 18 - content: z.string().optional(), 19 - notes: z.string().optional(), 20 - }), 21 - handler: async ( 22 - // yeah this is fucking insane 23 - { 24 - title, 25 - summary, 26 - tags, 27 - option, 28 - bskyUri, 29 - leafletUri, 30 - chapterTitle, 31 - content, 32 - notes, 33 - publish 34 - }, context) => { 11 + input: schema, 12 + handler: async ({ title, summary, tags, publish }, context) => { 35 13 const loggedInUser = context.locals.loggedInUser; 36 14 37 15 //#region "Check authentication" ··· 69 47 }; 70 48 //#endregion 71 49 72 - //#region "Import chapter from Bluesky or Leaflet" 73 - if (option !== "manual") { 74 - if (bskyUri) { 75 - const result = await fetchBskyPost(bskyUri); 76 - console.log("bsky post: " + JSON.stringify(result)); 77 - } 78 - if (leafletUri) { 79 - const result = await fetchLeaflet(leafletUri); 80 - console.log("leaflet: " + JSON.stringify(result)); 81 - } 82 - } 83 - //#endregion 84 - 85 50 //#region "Start publishing work to ATProto" 86 51 // we'll assign this after a successful request was made 87 52 let uri: string | undefined; 88 - let cUri: string | undefined; 53 + // let cUri: string | undefined; 89 54 90 55 if (publish) { 91 56 try { 92 - const rkey = TID.nextStr(); 93 57 const { tags, ...rest } = record; 94 58 95 - const result = await callSlices( 96 - "work", 97 - "createRecord", 98 - rkey, 99 - { 100 - ...rest, 101 - tags: [tags], 102 - author: loggedInUser.did, 103 - createdAt: createdAt.toISOString() 104 - } 105 - ); 59 + const result = await createFanficWork({ 60 + ...rest, 61 + tags: [tags], 62 + author: loggedInUser.did, 63 + createdAt: createdAt.toISOString(), 64 + }); 106 65 107 66 console.log(JSON.stringify(result)); 108 67 if (result.error) { 109 - console.error(`this went wrong for WORK: ${result.message}`); 110 68 throw new ActionError({ 111 69 code: "BAD_REQUEST", 112 - message: "Something went wrong!", 70 + message: "Something went wrong with posting your fic to your PDS!", 113 71 }); 114 72 } 115 73 uri = result.uri; 116 - 117 - //#region "Publish the first chapter with the work to ATProto" 118 - // ONLY proceed if the uri is set from successfully adding a work 119 - if (uri) { 120 - const crkey = TID.nextStr(rkey); 121 - let chapterContent = {}; 122 - 123 - if (option === "manual") { 124 - chapterContent = { 125 - $type: "fan.fics.work.chapter#chapterText", 126 - text: content, 127 - }; 128 - } 129 - 130 - const chapter = await callSlices( 131 - "work.chapter", 132 - "createRecord", 133 - crkey, 134 - { 135 - title: chapterTitle, 136 - content: chapterContent, 137 - createdAt: createdAt.toISOString(), 138 - workUri: uri, 139 - } 140 - ); 141 - 142 - console.log(JSON.stringify(chapter)); 143 - if (chapter.error) { 144 - console.error(`this went wrong: ${chapter.message}`); 145 - throw new ActionError({ 146 - code: "BAD_REQUEST", 147 - message: "Something went wrong!", 148 - }); 149 - } 150 - cUri = chapter.uri; 151 - //#endregion 152 - } 153 74 } catch (error) { 154 75 console.error(error); 155 76 throw new ActionError({ ··· 163 84 //#region "Add a new work to the database" 164 85 // check nanoid for collision probability: https://zelark.github.io/nano-id-cc/ 165 86 const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 166 - const nanoid = customAlphabet(alphabet, 16); 167 - const slug = nanoid(); 168 - 87 + const custom = customAlphabet(alphabet, 16); 88 + const slug = custom(); 89 + 169 90 const [work] = await db.insert(Works).values({ 170 91 uri, 171 92 slug, 172 93 createdAt, 173 94 author: user.did, 174 95 ...record, 175 - }).returning(); 96 + }).onConflictDoNothing({ target: Works.slug }).returning(); 176 97 177 - if (chapterTitle && content) { 178 - try { 179 - await addChapter( 180 - work.id, 181 - chapterTitle, 182 - content, 183 - cUri, 184 - notes, 185 - ); 186 - } catch (error) { 187 - console.error(error); 188 - throw new ActionError({ 189 - code: "BAD_REQUEST", 190 - message: "Something went wrong!", 191 - }); 192 - } 193 - } 194 - 195 98 return work; 99 + // const newWork = await db.transaction(async (tx) => { 100 + // const [work] = await tx.insert(Works).values({ 101 + // uri, 102 + // slug, 103 + // createdAt, 104 + // author: user.did, 105 + // ...record, 106 + // }).onConflictDoNothing({ target: Works.slug }).returning(); 107 + // if (!work) { tx.rollback(); } 108 + // const [chapter] = await tx.insert(Chapters).values({ 109 + // workId: work.id, 110 + // slug: nanoid(), 111 + // title: chapterTitle!, 112 + // content: content!, 113 + // uri: cUri, 114 + // authorsNotes: notes, 115 + // }).onConflictDoNothing({ target: Chapters.id }).returning(); 116 + // if (!chapter) { tx.rollback(); } 117 + // return work; 118 + // }); 119 + // return newWork; 196 120 //#endregion 197 121 }, 198 122 });
+6 -6
src/components/chapters/Chapter.astro
··· 10 10 <section> 11 11 <header> 12 12 <h1>{chapter.title}</h1> 13 - {chapter.authorsNotes && ( 13 + {chapter.warnings && ( 14 14 <details> 15 - <summary>Author's Notes</summary> 16 - <Fragment set:html={chapter.authorsNotes} /> 15 + <summary>Content warnings</summary> 16 + <Fragment set:html={chapter.warnings} /> 17 17 </details> 18 18 )} 19 19 <time datetime={chapter.createdAt.toISOString()}> ··· 26 26 </div> 27 27 28 28 <footer> 29 - {chapter.endNotes && ( 29 + {chapter.notes && ( 30 30 <aside> 31 - <p>End notes</p> 32 - <Fragment set:html={chapter.endNotes} /> 31 + <p>Author's Notes</p> 32 + <Fragment set:html={chapter.notes} /> 33 33 </aside> 34 34 )} 35 35 </footer>
+23 -1
src/lib/db.ts
··· 1 1 import slugify from "@sindresorhus/slugify"; 2 2 import { and, Chapters, db, eq, Tags, Works } from "astro:db"; 3 3 import type { Chapter } from "./types"; 4 + import { SQLiteOAuthStorage } from "@slices/oauth"; 5 + 6 + 7 + const OAUTH_CLIENT_ID = import.meta.env.OAUTH_CLIENT_ID; 8 + const OAUTH_CLIENT_SECRET = import.meta.env.OAUTH_CLIENT_SECRET; 9 + const OAUTH_REDIRECT_URI = import.meta.env.OAUTH_REDIRECT_URI; 10 + const OAUTH_AIP_BASE_URL = import.meta.env.OAUTH_AIP_BASE_URL; 11 + const API_URL = import.meta.env.API_URL; 12 + export const SLICE_URI = import.meta.env.SLICE_URI; 13 + 14 + // oauth session 15 + // const DATABASE_URL = import.meta.env.DATABASE_URL || "slices.db"; 16 + // const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); 17 + // const oauthConfig = { 18 + // clientId: OAUTH_CLIENT_ID, 19 + // clientSecret: OAUTH_CLIENT_SECRET, 20 + // authBaseUrl: OAUTH_AIP_BASE_URL, 21 + // redirectUri: OAUTH_REDIRECT_URI, 22 + // scopes: ["atproto", "openid", "profile", "transition:generic"], 23 + // }; 4 24 5 25 // fetch tags 6 26 export async function searchTags(search: string) { ··· 39 59 workId, 40 60 uri, 41 61 title, 62 + slug: "", 42 63 // order, 43 64 notes, 44 65 content, ··· 58 79 eq(Works.id, chapter.workId), 59 80 eq(Chapters.id, chapter.id) 60 81 )); 61 - } 82 + } 83 +
+40 -1
src/lib/types.ts
··· 3 3 export type Work = typeof Works.$inferSelect; 4 4 export type Chapter = typeof Chapters.$inferSelect; 5 5 export type Tag = typeof Tags.$inferSelect; 6 - export type User = typeof Users.$inferSelect; 6 + export type User = typeof Users.$inferSelect; 7 + 8 + export type atProtoWork = Omit<Work, "id" | "slug"> | { 9 + createdAt: string; 10 + updatedAt?: string; 11 + }; 12 + 13 + export type atProtoChapter = Omit<Chapter, "id" | "uri" | "slug" | "workId"> | { 14 + workUri: string; 15 + chapterRef?: string; 16 + content: ChapterText | BskyPost | LeafletDoc; 17 + createdAt: string; 18 + updatedAt?: string; 19 + }; 20 + 21 + export type atProtoComment = { 22 + content: string; 23 + createdAt: string; 24 + postedTo?: string; 25 + }; 26 + 27 + type comAtProtoStrongRef = { 28 + uri: string; 29 + cid: string; 30 + }; 31 + 32 + export type ChapterText = { 33 + $type: "fan.fics.work.chapter#chapterText"; 34 + text: string; 35 + }; 36 + 37 + export type BskyPost = { 38 + $type: "fan.fics.work.chapter#bskyPost"; 39 + postRef: comAtProtoStrongRef; 40 + }; 41 + 42 + export type LeafletDoc = { 43 + $type: "fan.fics.work.chapter#leafletDoc"; 44 + docRef: comAtProtoStrongRef; 45 + };
+2 -1
src/middleware.ts
··· 3 3 4 4 export const onRequest = defineMiddleware(async (context, next) => { 5 5 if (context.isPrerendered) return next(); 6 + // context.session?.set("oauth", "hey") // figure this out 7 + 6 8 const { action, setActionResult, serializeActionResult } = getActionContext(context); 7 - 8 9 const latestAction = await context.session?.get("latest-action"); 9 10 if (latestAction) { 10 11 setActionResult(latestAction.name, latestAction.result);