add editing work and deleting work functions

Changed files
+350 -72
db
src
actions
pages
+3 -2
db/config.ts
···
const Works = defineTable({
columns: {
id: column.number({ primaryKey: true }),
+
uri: column.text({ unique: true, optional: true }),
slug: column.text({ unique: true }),
-
author: column.text({ references: () => Users.columns.userDid }),
-
// recordkey
title: column.text(),
+
author: column.text({ references: () => Users.columns.userDid }),
content: column.text({ multiline: true }),
tags: column.json(),
createdAt: column.date({ name: "created_at", default: NOW }),
···
},
indexes: [
{ on: ["slug", "createdAt"], unique: true },
+
{ on: ["uri", "createdAt"], unique: true },
],
});
+191 -32
src/actions/works.ts
···
import { getAgent } from "@/lib/atproto";
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro:content";
-
import { db, eq, Users, Works } from "astro:db";
+
import { db, eq, and, Users, Works } from "astro:db";
import { customAlphabet } from "nanoid";
+
import { AtUri } from "@atproto/api";
const workSchema = z.object({
title: z.string(),
content: z.string(),
tags: z.string(),
-
public: z.boolean(),
+
publish: z.boolean(),
});
export const worksActions = {
addWork: defineAction({
accept: "form",
input: workSchema,
-
handler: async (input, context) => {
+
handler: async ({ title, content, tags, publish }, context) => {
const loggedInUser = context.locals.loggedInUser;
// check against auth
···
}
// find the did of the logged in user from our db
-
const query = await db
+
const [user] = await db
.select({ did: Users.userDid })
.from(Users)
.where(eq(Users.userDid, loggedInUser.did))
.limit(1);
-
if (query.length === 0) {
+
if (!user) {
throw new ActionError({
code: "UNAUTHORIZED",
message: "You can only add a work if you connected your PDS!",
});
}
-
const [user] = query;
+
// convert the tags into json thru shenaniganery
+
console.log(tags);
+
+
// we'll create a timestamp and record here
+
const createdAt = new Date();
+
const record = {
+
title,
+
content,
+
tags,
+
};
+
+
let uri; // we'll assign this after a successful request was made
+
// depending on whether someone toggled the privacy option, push this into user pds
+
if (publish) {
+
try {
+
const rkey = TID.nextStr();
+
const agent = await getAgent(context.locals);
+
+
if (!agent) {
+
console.error("Agent not found!");
+
throw new ActionError({
+
code: "BAD_REQUEST",
+
message: "Something went wrong when connecting to your PDS.",
+
});
+
}
+
+
// ideally, we'd like tags to be references to another record but we won't process them here
+
// we'll just smush this in and pray
+
const result = await agent.com.atproto.repo.createRecord({
+
repo: user.did,
+
collection: "moe.fanfics.works",
+
rkey,
+
record: {
+
...record,
+
createdAt: createdAt.toISOString(),
+
},
+
validate: false,
+
});
+
+
uri = result.data.uri;
+
} catch (error) {
+
console.error(error);
+
throw new ActionError({
+
code: "BAD_REQUEST",
+
message: "Something went wrong with posting your fic to your PDS!",
+
});
+
}
+
}
// check nanoid for collision probability: https://zelark.github.io/nano-id-cc/
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(alphabet, 16);
const slug = nanoid();
-
// convert the tags into json thru shenaniganery
-
const tags = input.tags;
-
const work = await db.insert(Works).values({
+
uri,
slug,
+
createdAt,
author: user.did,
-
title: input.title,
-
content: input.content,
-
tags,
+
...record,
}).returning();
const [newWork] = work;
+
return newWork;
+
},
+
}),
+
updateWork: defineAction({
+
accept: "form",
+
input: workSchema,
+
handler: async ({ title, content, tags }, context) => {
+
const workId = context.params["workId"];
+
const loggedInUser = context.locals.loggedInUser;
+
+
if (!loggedInUser) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You're not logged in!",
+
});
+
}
-
// depending on whether someone toggled the privacy option, push this into user pds
-
if (input.public) {
-
// we don't need the id, but we'll need the author's did
-
// we'll grab the created + updated timestamps and convert them into strings
-
const { author, id, createdAt, updatedAt, ...data } = newWork;
-
const createdTimestamp = createdAt.toISOString();
-
const record = {
-
...data,
-
createdAt: createdTimestamp,
-
};
-
+
const [work] = await db.select().from(Works)
+
.where(and(
+
eq(Works.slug, workId!),
+
eq(Works.author, loggedInUser.did)
+
))
+
.limit(1);
+
+
if (!work) {
+
throw new ActionError({
+
code: "NOT_FOUND",
+
message: "Could not find the work to update!",
+
});
+
}
+
+
const updatedAt = new Date();
+
// if the work has a uri, we should update the record to the pds
+
if (work.uri) {
try {
-
const rkey = TID.nextStr();
+
const { rkey, host } = new AtUri(work.uri);
const agent = await getAgent(context.locals);
if (!agent) {
console.error("Agent not found!");
throw new ActionError({
-
code: "BAD_REQUEST",
+
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong when connecting to your PDS.",
});
}
-
// ideally, we'd like tags to be references to another record but we won't process them here
+
if (loggedInUser.did !== host) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You can only update your own work!",
+
});
+
}
+
// we'll just smush this in and pray
const result = await agent.com.atproto.repo.putRecord({
-
repo: author, // since we KNOW that the author is the users' did
+
repo: work.author, // since the author will be a did
collection: "moe.fanfics.works",
rkey,
-
record,
+
record: {
+
title,
+
tags,
+
content,
+
updatedAt: updatedAt.toISOString(),
+
},
validate: false,
+
swapRecord: rkey, // idk what this does
});
-
return result.data.uri;
+
if (!result.success) {
+
throw new ActionError({
+
code: "INTERNAL_SERVER_ERROR",
+
message: "Something went wrong with updating your fic on your PDS!",
+
});
+
}
} catch (error) {
console.error(error);
throw new ActionError({
code: "BAD_REQUEST",
-
message: "Something went wrong with posting your fic to your PDS!",
+
message: "Something went wrong with updating your fic on your PDS!",
});
}
}
+
+
const [result] = await db.update(Works)
+
.set({
+
title,
+
tags,
+
content,
+
updatedAt,
+
})
+
.where(and(
+
eq(Works.slug, workId!),
+
eq(Works.author, loggedInUser.did)
+
))
+
.returning();
-
// otherwise just return the work
-
return newWork;
+
return result;
+
},
+
}),
+
deleteWork: defineAction({
+
accept: "form",
+
handler: async (_, context) => {
+
const workId = context.params["workId"];
+
const loggedInUser = context.locals.loggedInUser;
+
+
if (!loggedInUser) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You're not logged in!",
+
});
+
}
+
+
const [work] = await db.select().from(Works)
+
.where(and(
+
eq(Works.slug, workId!),
+
eq(Works.author, loggedInUser.did)
+
))
+
.limit(1);
+
+
if (!work) {
+
throw new ActionError({
+
code: "NOT_FOUND",
+
message: "Could not find the work!",
+
});
+
}
+
+
if (work.uri) {
+
try {
+
const { rkey, host } = new AtUri(work.uri);
+
const agent = await getAgent(context.locals);
+
+
if (!agent) {
+
console.error("Agent not found!");
+
throw new ActionError({
+
code: "INTERNAL_SERVER_ERROR",
+
message: "Something went wrong when connecting to your PDS.",
+
});
+
}
+
+
if (loggedInUser.did !== host) {
+
throw new ActionError({
+
code: "UNAUTHORIZED",
+
message: "You can only delete works that you own!",
+
});
+
}
+
+
// we'll just smush this in and pray
+
const result = await agent.com.atproto.repo.deleteRecord({
+
repo: work.author, // since the author will be a did
+
collection: "moe.fanfics.works",
+
rkey,
+
});
+
} catch (error) {
+
console.error(error);
+
throw new ActionError({
+
code: "BAD_REQUEST",
+
message: "Something went wrong with deleting your fic from your PDS!",
+
});
+
}
+
}
},
}),
};
-29
src/pages/works/[workId].astro
···
-
---
-
import WorkPage from "@/layouts/WorkPage.astro";
-
import { didToHandle } from "@/lib/atproto";
-
import { db, eq, Users, Works } from "astro:db";
-
-
const { workId } = Astro.params;
-
-
const work = await db.select()
-
.from(Works)
-
.where(eq(Works.slug, workId!))
-
.innerJoin(Users, eq(Works.author, Users.userDid))
-
.limit(1);
-
-
if (work.length === 0) {
-
return Astro.redirect("/not-found");
-
}
-
---
-
{work.map(async ({ Works, Users }) => (
-
<WorkPage
-
slug={Works.slug}
-
title={Works.title}
-
author={await didToHandle(Users.userDid)}
-
createdAt={Works.createdAt}
-
updatedAt={Works.updatedAt}
-
tags={Works.tags}
-
>
-
<Fragment set:html={Works.content} />
-
</WorkPage>
-
))}
+93
src/pages/works/[workId]/edit.astro
···
+
---
+
import Layout from "@/layouts/Layout.astro";
+
import { isInputError } from "astro:actions";
+
import { actions } from "astro:actions";
+
import { db, eq, Users, Works } from "astro:db";
+
+
const { workId } = Astro.params;
+
const loggedInUser = Astro.locals.loggedInUser;
+
+
// the work could be fetched from the database or from the pds
+
// would this potentially lighten db load? maybe
+
const [work] = await db.select()
+
.from(Works)
+
.where(eq(Works.slug, workId!))
+
.innerJoin(Users, eq(Works.author, Users.userDid))
+
.limit(1);
+
+
if (!work) {
+
return Astro.redirect("/not-found");
+
}
+
+
if (!loggedInUser) {
+
return Astro.redirect("/login");
+
}
+
+
if (work.Users.userDid !== loggedInUser.did) {
+
return Astro.redirect("/unauthorized");
+
}
+
+
const result = Astro.getActionResult(actions.worksActions.updateWork);
+
const errors = isInputError(result?.error) ? result.error.fields : {};
+
---
+
<Layout skipLink={`edit-${workId}`}>
+
<main id={`edit-${workId}`}>
+
<form action={actions.worksActions.updateWork} method="post">
+
<label for="title">title</label>
+
<input
+
type="text"
+
name="title"
+
id="title"
+
aria-describedby="title-error"
+
value={work.Works.title}
+
required
+
transition:persist
+
/>
+
{errors.title && (
+
<div id="title-error">
+
{errors.title}
+
</div>
+
)}
+
+
<label for="tags">add tags</label>
+
<input
+
type="text"
+
list="tags-list"
+
name="tags"
+
id="tags"
+
aria-describedby="tags-error"
+
value={work.Works.tags as string}
+
transition:persist
+
/>
+
<!-- could be cool to fetch tags from a tags server or smth? idk -->
+
<datalist id="tags-list">
+
<option value="test">here</option>
+
<option value="tag2">another</option>
+
<option value="tag3">try them all!</option>
+
</datalist>
+
{errors.tags && (
+
<div id="tags-error">
+
{errors.tags}
+
</div>
+
)}
+
+
<label for="content">body</label>
+
<textarea name="content" id="content" aria-describedby="content-error" transition:persist>
+
{work.Works.content}
+
</textarea>
+
{errors.content && (
+
<div id="content-error">
+
{errors.content}
+
</div>
+
)}
+
+
<button>submit</button>
+
</form>
+
+
{result?.error && (
+
<div class="error">
+
{result.error.message}
+
</div>
+
)}
+
</main>
+
</Layout>
+33
src/pages/works/[workId]/index.astro
···
+
---
+
import WorkPage from "@/layouts/WorkPage.astro";
+
import { didToHandle } from "@/lib/atproto";
+
import { db, eq, Users, Works } from "astro:db";
+
+
const { workId } = Astro.params;
+
const loggedInUser = Astro.locals.loggedInUser;
+
+
// the work could be fetched from the database or from the pds
+
// would this potentially lighten db load? maybe
+
const [work] = await db.select()
+
.from(Works)
+
.where(eq(Works.slug, workId!))
+
.innerJoin(Users, eq(Works.author, Users.userDid))
+
.limit(1);
+
+
if (!work) {
+
return Astro.redirect("/not-found");
+
}
+
---
+
<WorkPage
+
slug={work.Works.slug}
+
title={work.Works.title}
+
author={await didToHandle(work.Users.userDid)}
+
createdAt={work.Works.createdAt}
+
updatedAt={work.Works.updatedAt}
+
tags={work.Works.tags}
+
>
+
{(work.Users.userDid === loggedInUser?.did) && (
+
<a href={`/works/${workId}/edit`}>Edit your work</a>
+
)}
+
<Fragment set:html={work.Works.content} />
+
</WorkPage>
+30 -9
src/pages/works/add.astro
···
const loggedInUser = Astro.locals.loggedInUser;
if (!loggedInUser) {
-
Astro.redirect("/works");
+
// put in a thing that tells the user they're not logged in
+
return Astro.redirect("/works");
}
const result = Astro.getActionResult(actions.worksActions.addWork);
const errors = isInputError(result?.error) ? result.error.fields : {};
+
+
if (result && !result.error) {
+
return Astro.redirect(`/works/${result.data.slug}`);
+
// eventually we could return a uri and parse? it? i think?
+
}
---
-
<Layout>
-
<main>
+
<Layout skipLink="new-work">
+
<main id="new-work">
<h1>Add a new work</h1>
<form action={actions.worksActions.addWork} method="post">
<label for="title">title</label>
-
<input transition:persist type="text" name="title" id="title" aria-describedby="title-error" required />
+
<input
+
type="text"
+
name="title"
+
id="title"
+
aria-describedby="title-error"
+
required
+
transition:persist
+
/>
{errors.title && (
<div id="title-error">
{errors.title}
···
)}
<label for="tags">add tags</label>
-
<input transition:persist type="text" list="tags-list" name="tags" id="tags" aria-describedby="tags-error" />
+
<input
+
type="text"
+
list="tags-list"
+
name="tags"
+
id="tags"
+
aria-describedby="tags-error"
+
transition:persist
+
/>
<!-- could be cool to fetch tags from a tags server or smth? idk -->
<datalist id="tags-list">
<option value="test">here</option>
···
)}
<label for="content">body</label>
-
<textarea transition:persist name="content" id="content" aria-describedby="content-error"></textarea>
+
<!-- replace this with a rich text editor / code editor later -->
+
<textarea name="content" id="content" aria-describedby="content-error" transition:persist></textarea>
{errors.content && (
<div id="content-error">
{errors.content}
</div>
)}
-
-
<label for="public">Publish to your PDS?</label>
-
<input type="checkbox" name="public" id="public" />
+
+
<label for="publish">Publish to your PDS?</label>
+
<input type="checkbox" name="publish" id="publish" />
<button>submit</button>
</form>