(null);
@@ -139,7 +153,7 @@ export function StoryViewer({
// Image progress bar — fixed duration, unused for video stories (those
// advance on the player's "ended" event instead, see onVideoEnded below).
useEffect(() => {
- if (!open || !currentStory || isVideo) return;
+ if (!open || !currentStory || isVideo || deleteTargetId) return;
setProgress(0);
advancedRef.current = false;
@@ -159,7 +173,7 @@ export function StoryViewer({
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
- }, [open, currentStory, isVideo, advance]);
+ }, [open, currentStory, isVideo, advance, deleteTargetId]);
// Reset progress/advance-guard when a video story becomes current.
useEffect(() => {
@@ -228,6 +242,24 @@ export function StoryViewer({
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
}, [onOpenChange, queryClient, trpc, petId]);
+ const deleteStoryMutation = useMutation(
+ trpc.stories.delete.mutationOptions({
+ onSuccess: () => {
+ const remaining = stories.length - 1;
+ setDeleteTargetId(null);
+ toast.success(t("storyDeleted"));
+ void queryClient.invalidateQueries(trpc.stories.listActive.queryOptions({ petId }));
+ void queryClient.invalidateQueries(trpc.follows.pathFilter());
+ if (remaining <= 0) handleClose();
+ else setCurrentIndex((i) => Math.min(i, remaining - 1));
+ },
+ onError: () => {
+ setDeleteTargetId(null);
+ toast.error(t("storyDeleteError"));
+ },
+ })
+ );
+
if (!currentStory) return null;
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
@@ -399,10 +431,11 @@ export function StoryViewer({
- {/* Viewer count — top left (owner only) */}
+ {/* Viewer count + delete — top left (owner only) */}
{isOwnPet && (
+
+
+
)}
{/* Gitea #29 — paw + comment buttons, bottom right */}
@@ -493,6 +535,27 @@ export function StoryViewer({
)}
+ !next && !deleteStoryMutation.isPending && setDeleteTargetId(null)}>
+
+
+ {t("deleteStoryTitle")}
+ {t("deleteStoryDescription")}
+
+
+ {t("deleteStoryCancel")}
+ {
+ event.preventDefault();
+ if (deleteTargetId) deleteStoryMutation.mutate({ storyId: deleteTargetId });
+ }}
+ className="bg-red-600 text-white hover:bg-red-700"
+ >
+ {deleteStoryMutation.isPending ? : t("deleteStoryConfirm")}
+
+
+
+
>
);
}
diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts
index 5db984b..f5ca1f3 100644
--- a/src/trpc/routers/stories.ts
+++ b/src/trpc/routers/stories.ts
@@ -7,6 +7,7 @@ import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
+ deleteFiles,
} from "@/lib/supabase-storage";
import { randomUUID } from "crypto";
import { petIdentitySelect } from "@/repositories/pet-repository";
@@ -442,6 +443,38 @@ export const storiesRouter = router({
return comment;
}),
+ /**
+ * Owner löscht eine eigene Story (auch eine abgelaufene). Entfernt Aufrufe
+ * (StoryView hat bewusst kein onDelete: Cascade, D-08), dann die Story —
+ * Pfoten und Kommentare hängen per Cascade dran. Bild in Supabase Storage
+ * und Mux-Asset werden danach best-effort gelöscht: ein Fehler dort darf
+ * das Löschen für den Nutzer nicht scheitern lassen (nur geloggt).
+ */
+ delete: protectedProcedure
+ .use(rateLimited("stories.delete", 20, 60))
+ .input(z.object({ storyId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }): Promise<{ deleted: true }> => {
+ const story = await ctx.prisma.story.findUnique({
+ where: { id: input.storyId },
+ select: { id: true, petId: true, storageKey: true, muxAssetId: true },
+ });
+ if (!story) throw new TRPCError({ code: "NOT_FOUND" });
+ await assertPetOwnership(ctx.prisma, story.petId, ctx.userId);
+
+ await ctx.prisma.$transaction([
+ ctx.prisma.storyView.deleteMany({ where: { storyId: story.id } }),
+ ctx.prisma.story.delete({ where: { id: story.id } }),
+ ]);
+
+ if (story.storageKey) {
+ await deleteFiles([story.storageKey]).catch((err) => console.error("[stories] storage delete failed", err));
+ }
+ if (story.muxAssetId) {
+ await mux.video.assets.delete(story.muxAssetId).catch((err) => console.error("[stories] mux delete failed", err));
+ }
+ return { deleted: true };
+ }),
+
deleteComment: protectedProcedure
.input(z.object({ commentId: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }): Promise => {