diff --git a/app/(dashboard)/reward-gifts/page.tsx b/app/(dashboard)/reward-gifts/page.tsx
index 63266e6..dc54740 100644
--- a/app/(dashboard)/reward-gifts/page.tsx
+++ b/app/(dashboard)/reward-gifts/page.tsx
@@ -54,6 +54,26 @@ export default function RewardGiftsPage() {
}
};
+ const remove = async (reward: GiftableReward) => {
+ if (
+ !window.confirm(
+ `Delete “${reward.name}”? It disappears from the gallery of everyone who has it. Stickers already sent in chats stay put.`,
+ )
+ ) {
+ return;
+ }
+ setSending(reward.key);
+ try {
+ await rewardGifts.remove(reward.key);
+ setCatalog((prev) => prev?.filter((c) => c.key !== reward.key) ?? null);
+ toast.success("Deleted");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Could not delete");
+ } finally {
+ setSending(null);
+ }
+ };
+
return (
@@ -101,14 +121,29 @@ export default function RewardGiftsPage() {
{r.name}
-
+
+
+ {/* Seeded rewards have no delete: they would return on the
+ next seed run, having been taken from the galleries of
+ everyone who earned them. */}
+ {r.key.startsWith("custom_") && (
+
+ )}
+
))}
@@ -138,12 +173,23 @@ function CustomGift({
const upload = async (original: File) => {
setUploading(true);
try {
- // Same compression as the banner: artwork renders at ~120px in the
- // gallery and small again in a chat bubble, so a full-size original is
- // wasted bytes on every devotee's device.
- const file = await compressImage(original, { maxDimension: 512 }).catch(
- () => original,
- );
+ // WebP, NOT the helper's default JPEG: JPEG has no alpha channel, and
+ // compress-image paints a white backdrop behind it so transparency
+ // doesn't turn black. A sticker has to keep its cut-out, so it encodes
+ // to a format that has alpha — the white fill is skipped for anything
+ // that isn't JPEG.
+ //
+ // 1024px, not the gallery tile's 120px: the reward detail renders the
+ // artwork at 224 CSS px (rewards-gallery.tsx:184), which is 672 real
+ // pixels on a 3× screen — so anything smaller is upscaled and visibly
+ // soft exactly where the artwork is largest. Quality is raised too:
+ // stickers are flat illustration with hard edges, where WebP's default
+ // ringing shows far more than it does on a photograph.
+ const file = await compressImage(original, {
+ maxDimension: 1024,
+ quality: 0.95,
+ mimeType: "image/webp",
+ }).catch(() => original);
const { upload_url, image_url } = await rewardGifts.presignImage(
file.type,
);
@@ -217,7 +263,9 @@ function CustomGift({
{
const f = e.target.files?.[0];
diff --git a/lib/api.ts b/lib/api.ts
index bc399fd..b844d70 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -459,6 +459,12 @@ export const rewardGifts = {
{ method: "POST", body: JSON.stringify({ content_type: contentType }) },
).then((r) => r.data),
+ /** Delete a custom sticker. Cascades out of every gallery and vault. */
+ remove: (key: string) =>
+ call(`/admin/reward-gifts/${encodeURIComponent(key)}`, {
+ method: "DELETE",
+ }),
+
/** Add a sticker to the catalog. It reaches nobody until it is sent. */
create: (input: { name: string; meaning: string; image_url: string }) =>
call("/admin/reward-gifts/catalog", {