diff --git a/bun.lock b/bun.lock index 56414e5..ae1b738 100644 --- a/bun.lock +++ b/bun.lock @@ -13,8 +13,10 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "astro": "^7.1.1", + "lucide-react": "^1.28.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "wrangler": "^4.112.0", }, @@ -744,6 +746,8 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -892,6 +896,8 @@ "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], diff --git a/package.json b/package.json index a1c380c..1031797 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "astro": "^7.1.1", + "lucide-react": "^1.28.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "wrangler": "^4.112.0" }, diff --git a/src/actions/image-contract.test.ts b/src/actions/image-contract.test.ts index 2413e00..055a2c3 100644 --- a/src/actions/image-contract.test.ts +++ b/src/actions/image-contract.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import imageLexicon from "../../lexicons/pics/pixl/image.json"; import { MAX_IMAGE_BYTES } from "../config"; -import { imageCid, imageFile, imageUpload } from "./image-contract"; +import { imageCid, imageDeletion, imageFile, imageUpload } from "./image-contract"; describe("image action inputs", () => { test("accepts a non-empty image", () => { @@ -36,6 +36,16 @@ describe("image action inputs", () => { if (!result.success) expect(result.error.issues[0]?.message).toBe("Invalid image CID"); }); + test("validates image deletion references", () => { + const input = { + rkey: "3jxf7z2k3q2aa", + recordCid: "bafkreibjfgx2gprinfvicegelk5kosd6y2frmqpqzwqkg7usac74l3t2v4", + }; + expect(imageDeletion.parse(input)).toEqual(input); + expect(imageDeletion.safeParse({ ...input, rkey: "not-a-tid" }).success).toBeFalse(); + expect(imageDeletion.safeParse({ ...input, recordCid: "not-a-cid" }).success).toBeFalse(); + }); + test("keeps the upload and record limits aligned", () => { expect(imageLexicon.defs.main.record.properties.image.maxSize).toBe(MAX_IMAGE_BYTES); }); diff --git a/src/actions/image-contract.ts b/src/actions/image-contract.ts index ea84d38..765320d 100644 --- a/src/actions/image-contract.ts +++ b/src/actions/image-contract.ts @@ -1,4 +1,4 @@ -import { isCidString, isEncodingString } from "@atproto/lex"; +import { isCidString, isEncodingString, isTidString } from "@atproto/lex"; import { z } from "astro/zod"; import { MAX_IMAGE_BYTES } from "../config"; @@ -34,3 +34,8 @@ export const imageUpload = z.object({ export const imageCid = z.string().refine(isCidString, "Invalid image CID"); export const imageReference = z.object({ cid: imageCid }); + +export const imageDeletion = z.object({ + rkey: z.string().refine(isTidString, "Invalid image record key"), + recordCid: imageCid, +}); diff --git a/src/actions/images.ts b/src/actions/images.ts index 7cd279d..097cf08 100644 --- a/src/actions/images.ts +++ b/src/actions/images.ts @@ -3,7 +3,7 @@ import { ActionError, defineAction, type ActionAPIContext } from "astro:actions" import imageRecord from "../lexicons/pics/pixl/image"; import { getAtprotoClient } from "../lib/atproto-client"; import { getOAuthOrigin } from "../lib/atproto-oauth"; -import { imageReference, imageUpload } from "./image-contract"; +import { imageDeletion, imageReference, imageUpload } from "./image-contract"; import { z } from "astro/zod"; async function requireClient({ request, session, site }: ActionAPIContext) { @@ -58,6 +58,8 @@ export const images = { ? [ { cid: getBlobCidString(record.value.image), + recordCid: record.cid, + rkey: record.uri.slice(record.uri.lastIndexOf("/") + 1), alt: record.value.alt, createdAt: record.value.createdAt, }, @@ -78,4 +80,11 @@ export const images = { }; }, }), + delete: defineAction({ + input: imageDeletion, + handler: async ({ rkey, recordCid }, context) => { + const client = await requireClient(context); + await client.delete(imageRecord, { rkey, swapRecord: recordCid }); + }, + }), }; diff --git a/src/components/CopyImageUrlButton.tsx b/src/components/CopyImageUrlButton.tsx new file mode 100644 index 0000000..6488e78 --- /dev/null +++ b/src/components/CopyImageUrlButton.tsx @@ -0,0 +1,31 @@ +import { Copy } from "lucide-react"; +import { showToast } from "../lib/toast"; + +interface Props { + cid: string; +} + +export default function CopyImageUrlButton({ cid }: Props) { + async function copyUrl() { + const url = new URL(`/images/${cid}`, window.location.origin).href; + + try { + await navigator.clipboard.writeText(url); + showToast("success", "Image URL copied"); + } catch { + showToast("error", "Could not copy image URL"); + } + } + + return ( + + + + ); +} diff --git a/src/components/DeleteImageButton.tsx b/src/components/DeleteImageButton.tsx new file mode 100644 index 0000000..1e9c5da --- /dev/null +++ b/src/components/DeleteImageButton.tsx @@ -0,0 +1,86 @@ +import { actions } from "astro:actions"; +import { LoaderCircle, Trash2 } from "lucide-react"; +import { useRef, useState } from "react"; +import { showToast } from "../lib/toast"; + +interface Props { + cardId: string; + recordCid: string; + rkey: string; +} + +export default function DeleteImageButton({ cardId, recordCid, rkey }: Props) { + const dialog = useRef(null); + const [isDeleting, setIsDeleting] = useState(false); + + async function deleteImage() { + setIsDeleting(true); + + try { + const { error } = await actions.images.delete({ recordCid, rkey }); + if (error) { + showToast("error", error.message); + return; + } + + dialog.current?.close(); + document.getElementById(cardId)?.remove(); + showToast("success", "Image deleted"); + } catch { + showToast("error", "Could not delete image"); + } finally { + setIsDeleting(false); + } + } + + return ( + <> + dialog.current?.showModal()} + > + + + isDeleting && event.preventDefault()} + > + + + Delete this image? + + This deletes the image from your PDS and cannot be undone. + + + + dialog.current?.close()} + > + Cancel + + + {isDeleting ? ( + + ) : ( + + )} + Delete + + + + + > + ); +} diff --git a/src/components/ImageCard.astro b/src/components/ImageCard.astro index a867e20..34df555 100644 --- a/src/components/ImageCard.astro +++ b/src/components/ImageCard.astro @@ -1,26 +1,48 @@ --- import type { DatetimeString } from "@atproto/lex"; +import CopyImageUrlButton from "./CopyImageUrlButton"; +import DeleteImageButton from "./DeleteImageButton"; interface Props { cid: string; + recordCid: string; + rkey: string; alt: string; createdAt: DatetimeString; } -const { cid, alt, createdAt } = Astro.props; +const { cid, recordCid, rkey, alt, createdAt } = Astro.props; +const cardId = `image-${rkey}`; --- - + + - - {alt || "No alt text"} - - {new Date(createdAt).toLocaleString()} - + + + + {alt || "No alt text"} + + + + + + + {new Date(createdAt).toLocaleString()} + + + diff --git a/src/components/ToastProvider.tsx b/src/components/ToastProvider.tsx new file mode 100644 index 0000000..828f471 --- /dev/null +++ b/src/components/ToastProvider.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import { Toaster, toast } from "sonner"; +import { toastEventName, type ToastMessage } from "../lib/toast"; + +export default function ToastProvider() { + useEffect(() => { + function showToast(event: Event) { + const { type, message } = (event as CustomEvent).detail; + toast[type](message); + } + + window.addEventListener(toastEventName, showToast); + return () => window.removeEventListener(toastEventName, showToast); + }, []); + + return ; +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 5d4af7c..e51c0f6 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,7 @@ --- +import ToastProvider from "../components/ToastProvider"; import "../styles/global.css"; +import "sonner/dist/styles.css"; interface Props { title?: string; } @@ -17,5 +19,6 @@ const { title = "pixl" } = Astro.props; +
+ This deletes the image from your PDS and cannot be undone. +