feat: eigene Story löschen (Web + App)
- stories.delete: nur Owner; Aufrufe, Story (Pfoten/Kommentare per Cascade), danach Bild in Supabase Storage bzw. Mux-Asset best-effort - Web: Papierkorb neben der Zuschauerzahl + Rückfrage, Timer pausiert, gelöscht wird die beim Antippen gemerkte Story - App: gleicher Papierkorb mit nativer Rückfrage, Timer pausiert - found.md: Nutzer-Feedback "Story löschbar" erledigt, 7-Tage-Wunsch mit Befund als offener Punkt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ec5d8c5f4
commit
81d9c6da30
9 files changed
+259
-27
No files matched your search
+8
-1
@@ -581,7 +581,14 @@
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Ton einschalten",
|
||||
"videoError": "Video konnte nicht verarbeitet werden.",
|
||||
"openComments": "Kommentare öffnen"
|
||||
"openComments": "Kommentare öffnen",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteStoryTitle": "Story löschen?",
|
||||
"deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteStoryCancel": "Abbrechen",
|
||||
"deleteStoryConfirm": "Löschen",
|
||||
"storyDeleted": "Story gelöscht",
|
||||
"storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal."
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Storys",
|
||||
|
||||
+8
-1
@@ -581,7 +581,14 @@
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"videoError": "Video could not be processed.",
|
||||
"openComments": "Open comments"
|
||||
"openComments": "Open comments",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteStoryTitle": "Delete story?",
|
||||
"deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteStoryCancel": "Cancel",
|
||||
"deleteStoryConfirm": "Delete",
|
||||
"storyDeleted": "Story deleted",
|
||||
"storyDeleteError": "The story could not be deleted. Please try again."
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Stories",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { Eye, MessageCircle, X } from "lucide-react-native";
|
||||
import { Eye, MessageCircle, Trash2, X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
||||
@@ -17,15 +17,15 @@ import { colors } from "../../src/theme/tokens";
|
||||
|
||||
const STORY_DURATION_MS = 5000;
|
||||
|
||||
type Sheet = "comments" | "viewers" | null;
|
||||
type Sheet = "comments" | "viewers" | "confirm-delete" | null;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
|
||||
* stories (no Mux video story playback — see story-new.tsx). Tap the left
|
||||
* third to go back a story, the rest to skip ahead; a fixed timer
|
||||
* auto-advances. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene
|
||||
* Story — die Zuschauerliste wie im Web; solange ein Sheet offen ist, hält
|
||||
* der Timer an.
|
||||
* Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder
|
||||
* die Lösch-Rückfrage offen ist, hält der Timer an.
|
||||
*/
|
||||
export default function StoryViewerScreen() {
|
||||
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
|
||||
@@ -53,6 +53,37 @@ export default function StoryViewerScreen() {
|
||||
const isPaused = openSheet !== null;
|
||||
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.stories.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
const remaining = stories.length - 1;
|
||||
setOpenSheet(null);
|
||||
void queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
void queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
if (remaining <= 0) router.back();
|
||||
else setIndex((i) => Math.min(i, remaining - 1));
|
||||
},
|
||||
onError: () => {
|
||||
setOpenSheet(null);
|
||||
Alert.alert(t("storyViewer.deleteError"));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story.
|
||||
const confirmDelete = (storyId: string) => {
|
||||
setOpenSheet("confirm-delete");
|
||||
Alert.alert(
|
||||
t("storyViewer.deleteTitle"),
|
||||
t("storyViewer.deleteDescription"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) },
|
||||
{ text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => setOpenSheet(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const { data: viewers = [] } = useQuery({
|
||||
...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||
enabled: isOwnStory && !!currentStory,
|
||||
@@ -155,15 +186,27 @@ export default function StoryViewerScreen() {
|
||||
</View>
|
||||
|
||||
{isOwnStory && (
|
||||
<Pressable
|
||||
style={[styles.viewerCount, { top: insets.top + 8 }]}
|
||||
onPress={() => setOpenSheet("viewers")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
|
||||
>
|
||||
<Eye size={16} color={colors.pureWhite} />
|
||||
<Text style={styles.viewerCountText}>{viewers.length}</Text>
|
||||
</Pressable>
|
||||
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
|
||||
<Pressable
|
||||
style={styles.viewerCount}
|
||||
onPress={() => setOpenSheet("viewers")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
|
||||
>
|
||||
<Eye size={16} color={colors.pureWhite} />
|
||||
<Text style={styles.viewerCountText}>{viewers.length}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.deleteButton}
|
||||
onPress={() => confirmDelete(currentStory.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.deleteStory")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activePetId && (
|
||||
@@ -238,9 +281,9 @@ const styles = StyleSheet.create({
|
||||
actionWrap: { alignItems: "center", gap: 2 },
|
||||
actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||
ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 },
|
||||
deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
viewerCount: {
|
||||
position: "absolute",
|
||||
left: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
@@ -248,7 +291,6 @@ const styles = StyleSheet.create({
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
zIndex: 2,
|
||||
},
|
||||
viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" },
|
||||
closeButton: {
|
||||
|
||||
+4
-1
@@ -156,7 +156,10 @@ Neu 7:
|
||||
2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
|
||||
Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. Rückmeldung des Nutzers steht noch aus.
|
||||
|
||||
- Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
|
||||
- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
|
||||
→ Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet.
|
||||
- [ ] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage)
|
||||
→ Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung.
|
||||
|
||||
|
||||
|
||||
@@ -411,6 +411,11 @@
|
||||
"commentError": "Kommentar konnte nicht gesendet werden. Bitte versuch es noch einmal.",
|
||||
"viewersTitle_one": "{{count}} Aufruf",
|
||||
"viewersTitle_other": "{{count}} Aufrufe",
|
||||
"noViewers": "Noch hat niemand deine Story gesehen."
|
||||
"noViewers": "Noch hat niemand deine Story gesehen.",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteTitle": "Story löschen?",
|
||||
"deleteDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteConfirm": "Löschen",
|
||||
"deleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal."
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,11 @@
|
||||
"commentError": "Your comment could not be sent. Please try again.",
|
||||
"viewersTitle_one": "{{count}} view",
|
||||
"viewersTitle_other": "{{count}} views",
|
||||
"noViewers": "Nobody has seen your story yet."
|
||||
"noViewers": "Nobody has seen your story yet.",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteTitle": "Delete story?",
|
||||
"deleteDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteConfirm": "Delete",
|
||||
"deleteError": "The story could not be deleted. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,16 @@ vi.mock("@/lib/supabase-storage", () => ({
|
||||
}),
|
||||
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024,
|
||||
deleteFiles: (...args: unknown[]) => mockDeleteFiles(...args),
|
||||
}));
|
||||
|
||||
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
|
||||
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/mux", () => ({
|
||||
mux: { video: { assets: { delete: (...args: unknown[]) => mockMuxDelete(...args), retrieve: vi.fn() } } },
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
|
||||
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
||||
}));
|
||||
@@ -141,6 +149,65 @@ describe("stories.create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.delete", () => {
|
||||
beforeEach(() => {
|
||||
mockDeleteFiles.mockClear();
|
||||
mockMuxDelete.mockClear();
|
||||
});
|
||||
|
||||
it("deletes the owner's photo story with its views and storage file", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s1", petId: "pet-1", storageKey: "stories/a.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await expect(caller.delete({ storyId: "s1" })).resolves.toEqual({ deleted: true });
|
||||
|
||||
expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "s1" } });
|
||||
expect(mockPrisma.story.delete).toHaveBeenCalledWith({ where: { id: "s1" } });
|
||||
expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]);
|
||||
expect(mockMuxDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also removes the Mux asset of a video story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s2", petId: "pet-1", storageKey: null, muxAssetId: "asset-9" });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await caller.delete({ storyId: "s2" });
|
||||
|
||||
expect(mockMuxDelete).toHaveBeenCalledWith("asset-9");
|
||||
expect(mockDeleteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to delete another owner's story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s3", petId: "pet-2", storageKey: "stories/b.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-2" });
|
||||
|
||||
await expect(caller.delete({ storyId: "s3" })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(mockDeleteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still succeeds when the storage cleanup fails", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s4", petId: "pet-1", storageKey: "stories/c.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
mockDeleteFiles.mockRejectedValueOnce(new Error("storage down"));
|
||||
|
||||
await expect(caller.delete({ storyId: "s4" })).resolves.toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for unknown stories", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue(null);
|
||||
await expect(caller.delete({ storyId: "nope" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.listActive", () => {
|
||||
it("marks stories the viewer already pawed and strips the raw reactions", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle } from "lucide-react";
|
||||
import { X, Eye, Volume2, VolumeX, Loader2, MessageCircle, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -70,6 +81,9 @@ export function StoryViewer({
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [viewerSheetOpen, setViewerSheetOpen] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
// Story-ID beim Antippen des Papierkorbs festhalten — der Timer kann
|
||||
// (bei Videos) weiterlaufen, gelöscht wird trotzdem genau diese Story.
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const advancedRef = useRef(false);
|
||||
const musicAudioRef = useRef<HTMLAudioElement>(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({
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Viewer count — top left (owner only) */}
|
||||
{/* Viewer count + delete — top left (owner only) */}
|
||||
{isOwnPet && (
|
||||
<div className="absolute top-8 left-4 flex items-center gap-2">
|
||||
<button
|
||||
className="absolute top-8 left-4 flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
|
||||
onClick={() => setViewerSheetOpen(true)}
|
||||
aria-label={
|
||||
viewers.length === 1
|
||||
@@ -413,6 +446,15 @@ export function StoryViewer({
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{viewers.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-black/40 text-white hover:bg-red-600/80 focus-visible:outline-2 focus-visible:outline-white"
|
||||
onClick={() => setDeleteTargetId(currentStory.id)}
|
||||
aria-label={t("deleteStory")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gitea #29 — paw + comment buttons, bottom right */}
|
||||
@@ -493,6 +535,27 @@ export function StoryViewer({
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={!!deleteTargetId} onOpenChange={(next) => !next && !deleteStoryMutation.isPending && setDeleteTargetId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteStoryTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteStoryDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteStoryMutation.isPending}>{t("deleteStoryCancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteStoryMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (deleteTargetId) deleteStoryMutation.mutate({ storyId: deleteTargetId });
|
||||
}}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{deleteStoryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("deleteStoryConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<void> => {
|
||||
|
||||
Reference in new issue
Block a user