wip(mobile): Story-Editor-Oberflaeche (Editor, Pickers, Viewer, Mitteilungen)
Typecheck gruen. Offen: Lint (React-Compiler-Regeln in EditableOverlay, setState-in-effect im Viewer), Web-Anzeige, Geraetetest, Deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
795a39355e
commit
0c448de4c1
15 files changed
+1341
-155
No files matched your search
@@ -22,6 +22,9 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
PAW_BACK: "notifications.pawBack",
|
||||
PET_SUGGESTION: "notifications.petSuggestion",
|
||||
REFERRAL_JOINED: "notifications.referralJoined",
|
||||
STORY_MENTION: "notifications.storyMention",
|
||||
STORY_COMMENT: "notifications.storyComment",
|
||||
STORY_REACTION: "notifications.storyReaction",
|
||||
};
|
||||
|
||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||
@@ -66,6 +69,21 @@ const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
* there so this doesn't try to be smarter about it).
|
||||
*/
|
||||
function getNotificationRoute(item: NotificationItem): string | null {
|
||||
// Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht
|
||||
// eigene Storys 7 Tage), sonst das Tierprofil.
|
||||
if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) {
|
||||
const isOwnStory = item.story.petId === item.recipientPetId;
|
||||
const isRunning = new Date(item.story.expiresAt).getTime() > Date.now();
|
||||
if (isRunning || isOwnStory) {
|
||||
const params = new URLSearchParams({
|
||||
petId: item.story.petId,
|
||||
petName: item.story.pet.name,
|
||||
petAvatarKey: item.story.pet.avatarKey ?? "",
|
||||
});
|
||||
return `/story-viewer?${params.toString()}`;
|
||||
}
|
||||
return `/pets/${item.story.petId}`;
|
||||
}
|
||||
switch (item.type) {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
|
||||
+164
-142
@@ -1,39 +1,32 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image as RNImage,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState, useCallback, useMemo, useLayoutEffect } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera, Type } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
|
||||
function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo or short video story creation. The video path mirrors web's
|
||||
* StoryForm.tsx: stories.createVideoUpload gets a Mux direct-upload URL and
|
||||
* creates the (PROCESSING) Story row up front, then the raw bytes go
|
||||
* straight to Mux, never through our server — story-viewer.tsx polls
|
||||
* getVideoStatus once this screen has already closed, same as web's viewer.
|
||||
* Single asset, unlike post-new.tsx's multi-image picker — a story is one
|
||||
* photo or one video.
|
||||
* Story erstellen: Foto, kurzes Video oder reine Text-Story (Farbverlauf),
|
||||
* danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe
|
||||
* src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild
|
||||
* gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload
|
||||
* legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der
|
||||
* Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher.
|
||||
*/
|
||||
export default function NewStoryScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,35 +38,31 @@ export default function NewStoryScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
|
||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
const editorEnabled = flags?.storyEditor ?? false;
|
||||
|
||||
const [media, setMedia] = useState<EditorMedia | null>(null);
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden.
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({ headerShown: media === null });
|
||||
}, [navigation, media]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird.
|
||||
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
|
||||
|
||||
// expo-image-picker reports `duration` (ms) only for videos, `null`/`undefined`
|
||||
// for photos — more reliable than trusting a MIME-type string (see the
|
||||
// Android codec-suffix lesson in web's StoryForm.tsx handleFile()).
|
||||
const isVideo = asset?.duration != null;
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||
const createMutation = useMutation(trpc.stories.create.mutationOptions());
|
||||
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
|
||||
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.stories.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
const createVideoUploadMutation = useMutation(
|
||||
trpc.stories.createVideoUpload.mutationOptions({
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
const finish = useCallback(async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
}, [queryClient, trpc, router]);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -84,12 +73,17 @@ export default function NewStoryScreen() {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
|
||||
if (result.canceled) return;
|
||||
|
||||
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
|
||||
const picked = result.assets[0];
|
||||
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
if (picked.duration != null) {
|
||||
if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
return;
|
||||
}
|
||||
setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined });
|
||||
return;
|
||||
}
|
||||
setAsset(picked);
|
||||
setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height });
|
||||
}, [t]);
|
||||
|
||||
const takePhoto = useCallback(async () => {
|
||||
@@ -99,88 +93,120 @@ export default function NewStoryScreen() {
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!asset || !activePetId) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isVideo) {
|
||||
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
aiDisclosure: "NONE",
|
||||
});
|
||||
setIsUploadingFile(true);
|
||||
setUploadPercent(0);
|
||||
await uploadFileToUrl(asset.uri, uploadUrl ?? "", asset.mimeType ?? "video/mp4", (fraction) => {
|
||||
const pct = Math.round(fraction * 100);
|
||||
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||
});
|
||||
// Story row already exists (PROCESSING) — story-viewer.tsx polls
|
||||
// getVideoStatus for it, same as web's viewer. Nothing to await here.
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
setIsUploadingFile(true);
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
||||
} catch {
|
||||
setError(t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploadingFile(false);
|
||||
setUploadPercent(null);
|
||||
if (!result.canceled) {
|
||||
const photo = result.assets[0];
|
||||
setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||
}
|
||||
}, [asset, activePetId, isVideo, createVideoUploadMutation, getPresignedUrlMutation, createMutation, queryClient, trpc, router]);
|
||||
}, [t]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async ({ overlays, background }: EditorResult) => {
|
||||
if (!media || !activePetId) return;
|
||||
setError(null);
|
||||
const editorFields = overlays.length > 0 ? { overlays } : {};
|
||||
|
||||
try {
|
||||
if (media.kind === "text") {
|
||||
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.kind === "video") {
|
||||
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
aiDisclosure: "NONE",
|
||||
...editorFields,
|
||||
...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}),
|
||||
});
|
||||
setIsUploading(true);
|
||||
setUploadPercent(0);
|
||||
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
|
||||
const pct = Math.round(fraction * 100);
|
||||
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||
});
|
||||
// Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab.
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
mediaHeight: size.height,
|
||||
});
|
||||
await finish();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadPercent(null);
|
||||
}
|
||||
},
|
||||
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||
);
|
||||
|
||||
const isSubmitting =
|
||||
getPresignedUrlMutation.isPending || createMutation.isPending || createVideoUploadMutation.isPending || isUploadingFile;
|
||||
isUploading ||
|
||||
getPresignedUrlMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
createTextMutation.isPending ||
|
||||
createVideoUploadMutation.isPending;
|
||||
|
||||
if (media && activePetId) {
|
||||
return (
|
||||
<StoryEditor
|
||||
media={media}
|
||||
petId={activePetId}
|
||||
toolsEnabled={editorEnabled}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (!isSubmitting) {
|
||||
setMedia(null);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
progress={
|
||||
<>
|
||||
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
|
||||
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{asset ? (
|
||||
<Pressable style={styles.preview} onPress={() => setAsset(null)} disabled={isSubmitting}>
|
||||
{isVideo ? (
|
||||
<VideoPreview uri={asset.uri} style={styles.previewImage} />
|
||||
) : (
|
||||
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
|
||||
)}
|
||||
<Text style={styles.previewHint}>{isVideo ? t("story.changeVideoHint") : t("story.changePhotoHint")}</Text>
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{editorEnabled && (
|
||||
<Pressable style={styles.textStoryButton} onPress={() => setMedia({ kind: "text" })}>
|
||||
<Type color={colors.pureWhite} size={22} />
|
||||
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onPress={handleSubmit}
|
||||
disabled={!asset || isSubmitting}
|
||||
/>
|
||||
{isSubmitting && isVideo ? (
|
||||
<UploadProgress percent={uploadPercent} styles={styles} />
|
||||
) : (
|
||||
isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -210,17 +236,6 @@ function UploadProgress({ percent, styles }: { percent: number | null; styles: R
|
||||
);
|
||||
}
|
||||
|
||||
/** Muted, looping local-file preview — separate component so `useVideoPlayer` isn't called conditionally in the screen body. */
|
||||
function VideoPreview({ uri, style }: { uri: string; style: StyleProp<ViewStyle> }) {
|
||||
const player = useVideoPlayer(uri, (p) => {
|
||||
p.loop = true;
|
||||
p.muted = true;
|
||||
p.play();
|
||||
});
|
||||
|
||||
return <VideoView player={player} style={style} contentFit="contain" nativeControls={false} />;
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
@@ -244,13 +259,20 @@ function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
|
||||
previewImage: { width: "100%", flex: 1 },
|
||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
textStoryButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
},
|
||||
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
|
||||
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
|
||||
progressWrap: { gap: 6 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: colors.hairlineGray, overflow: "hidden" },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
|
||||
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
|
||||
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: colors.ink },
|
||||
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
@@ -15,11 +15,15 @@ import { StoryPawButton } from "../../src/components/stories/StoryPawButton";
|
||||
import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet";
|
||||
import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet";
|
||||
import { getMediaUrl } from "../../src/lib/media-url";
|
||||
import { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
|
||||
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
|
||||
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
|
||||
import { colors } from "../../src/theme/tokens";
|
||||
|
||||
const IMAGE_STORY_DURATION_MS = 5000;
|
||||
|
||||
type Sheet = "comments" | "viewers" | "confirm-delete" | null;
|
||||
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
|
||||
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
|
||||
@@ -44,7 +48,7 @@ export default function StoryViewerScreen() {
|
||||
const isOwnStory = activePetId === petId;
|
||||
|
||||
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
|
||||
const { data: stories = [] } = useQuery(listQuery);
|
||||
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [openSheet, setOpenSheet] = useState<Sheet>(null);
|
||||
@@ -54,6 +58,18 @@ export default function StoryViewerScreen() {
|
||||
const currentStory = stories[index];
|
||||
const isPaused = openSheet !== null;
|
||||
const isVideo = currentStory?.mediaType === "VIDEO";
|
||||
const isText = currentStory?.mediaType === "TEXT";
|
||||
const [screen, setScreen] = useState({ width: 0, height: 0 });
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
|
||||
const showOverlays = flags?.storyEditor ?? false;
|
||||
|
||||
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
|
||||
}, [])
|
||||
);
|
||||
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
@@ -115,6 +131,14 @@ export default function StoryViewerScreen() {
|
||||
[router]
|
||||
);
|
||||
|
||||
const openMentionedPet = useCallback(
|
||||
(targetPetId: string) => {
|
||||
setOpenSheet("away");
|
||||
router.push(`/pets/${targetPetId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (advancedRef.current) return;
|
||||
advancedRef.current = true;
|
||||
@@ -166,13 +190,25 @@ export default function StoryViewerScreen() {
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [currentStory, isVideo, isPaused, advance]);
|
||||
|
||||
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
|
||||
useEffect(() => {
|
||||
if (storiesLoaded && stories.length === 0) router.back();
|
||||
}, [storiesLoaded, stories.length, router]);
|
||||
|
||||
if (!currentStory) return null;
|
||||
|
||||
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
|
||||
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
|
||||
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
|
||||
const mediaSize = storyMediaSize(currentStory);
|
||||
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.backdrop} onLayout={onLayout}>
|
||||
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
|
||||
{stories.map((s, i) => (
|
||||
<View key={s.id} style={styles.progressTrack}>
|
||||
@@ -195,6 +231,12 @@ export default function StoryViewerScreen() {
|
||||
onProgress={setProgress}
|
||||
onEnded={advance}
|
||||
/>
|
||||
) : isText ? (
|
||||
rect && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
|
||||
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
|
||||
)}
|
||||
@@ -204,6 +246,15 @@ export default function StoryViewerScreen() {
|
||||
<Pressable style={styles.tapZoneRight} onPress={advance} />
|
||||
</View>
|
||||
|
||||
{showOverlays && rect && (
|
||||
<StoryOverlayLayer
|
||||
overlays={currentStory.overlays}
|
||||
rect={rect}
|
||||
mentionedPets={currentStory.mentionedPets}
|
||||
onPressMention={openMentionedPet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
|
||||
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
|
||||
<Text style={styles.petName}>{petName}</Text>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { PositionedOverlay } from "./StoryOverlayLayer";
|
||||
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
const TAP_MAX_MOVE = 6;
|
||||
const TAP_MAX_MS = 250;
|
||||
|
||||
type Props = {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
mentionName?: string;
|
||||
onChange: (next: StoryOverlay) => void;
|
||||
onTap: () => void;
|
||||
/** Ziehen beginnt/endet — der Editor blendet dann den Papierkorb ein bzw. aus. */
|
||||
onDragStateChange: (dragging: boolean, pageY: number) => void;
|
||||
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
|
||||
isOverTrash: (pageY: number) => boolean;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
|
||||
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
|
||||
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
|
||||
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
|
||||
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
|
||||
*
|
||||
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
|
||||
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
|
||||
*/
|
||||
export function EditableOverlay({ overlay, rect, mentionName, onChange, onTap, onDragStateChange, isOverTrash, onDelete }: Props) {
|
||||
// Aktuelle Props für die (einmal erzeugten) PanResponder-Handler.
|
||||
const latest = useRef({ overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete });
|
||||
latest.current = { overlay, rect, onChange, onTap, onDragStateChange, isOverTrash, onDelete };
|
||||
|
||||
const gesture = useRef({
|
||||
base: overlay,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
touches: 0,
|
||||
startDistance: 1,
|
||||
startAngle: 0,
|
||||
startCentroid: { x: 0, y: 0 },
|
||||
startedAt: 0,
|
||||
moved: 0,
|
||||
});
|
||||
|
||||
const responder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: (evt) => {
|
||||
const g = gesture.current;
|
||||
g.base = latest.current.overlay;
|
||||
g.offsetX = 0;
|
||||
g.offsetY = 0;
|
||||
g.touches = 0;
|
||||
g.startedAt = Date.now();
|
||||
g.moved = 0;
|
||||
rebase(evt, { dx: 0, dy: 0 } as PanResponderGestureState);
|
||||
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderMove: (evt, state) => {
|
||||
const g = gesture.current;
|
||||
const touches = evt.nativeEvent.touches;
|
||||
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
|
||||
if (touches.length !== g.touches) rebase(evt, state);
|
||||
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
|
||||
|
||||
let pinch = 1;
|
||||
let rotationDelta = 0;
|
||||
let dx = state.dx - g.offsetX;
|
||||
let dy = state.dy - g.offsetY;
|
||||
if (touches.length >= 2 && g.startDistance > 0) {
|
||||
const now = touchGeometry(touches[0], touches[1]);
|
||||
pinch = now.distance / g.startDistance;
|
||||
rotationDelta = angleDelta(g.startAngle, now.angle);
|
||||
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
|
||||
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
|
||||
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
|
||||
}
|
||||
latest.current.onChange(applyGesture(g.base, latest.current.rect, { dx, dy, pinch, rotationDelta }));
|
||||
latest.current.onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderRelease: (evt) => finish(evt),
|
||||
onPanResponderTerminate: (evt) => finish(evt),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
|
||||
const g = gesture.current;
|
||||
const touches = evt.nativeEvent.touches;
|
||||
g.base = latest.current.overlay;
|
||||
g.offsetX = state.dx;
|
||||
g.offsetY = state.dy;
|
||||
g.touches = touches.length;
|
||||
if (touches.length >= 2) {
|
||||
const geo = touchGeometry(touches[0], touches[1]);
|
||||
g.startDistance = geo.distance;
|
||||
g.startAngle = geo.angle;
|
||||
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
function finish(evt: GestureResponderEvent) {
|
||||
const g = gesture.current;
|
||||
const { pageY } = evt.nativeEvent;
|
||||
latest.current.onDragStateChange(false, pageY);
|
||||
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
|
||||
latest.current.onTap();
|
||||
} else if (latest.current.isOverTrash(pageY)) {
|
||||
latest.current.onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PositionedOverlay overlay={overlay} rect={rect} interactive>
|
||||
<View {...responder.panHandlers} hitSlop={12}>
|
||||
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={mentionName} />
|
||||
</View>
|
||||
</PositionedOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, useWindowDimensions, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
export type PickedGif = { id: string; url: string; width: number; height: number };
|
||||
|
||||
const COLUMNS = 3;
|
||||
const GAP = 6;
|
||||
|
||||
/**
|
||||
* GIF-Suche über unseren Server (stories.searchGifs → GIPHY, nur Freigabe G).
|
||||
* Ohne Suchbegriff erscheinen die aktuellen Trends. „Powered by GIPHY“ ist
|
||||
* Pflicht laut GIPHY-Bedingungen.
|
||||
*/
|
||||
export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (gif: PickedGif) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const { width } = useWindowDimensions();
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), 400);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [items, setItems] = useState<PickedGif[]>([]);
|
||||
|
||||
// Neue Suche → von vorne
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Liste beim Suchwechsel leeren ist gewollt
|
||||
setOffset(0);
|
||||
setItems([]);
|
||||
}, [debounced]);
|
||||
|
||||
const { data, isFetching, isError } = useQuery({
|
||||
...trpc.stories.searchGifs.queryOptions({ query: debounced, offset }),
|
||||
enabled: visible,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Seiten werden angehängt, sobald sie da sind
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((g) => g.id));
|
||||
return [...prev, ...data.gifs.filter((g) => !seen.has(g.id))];
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const tile = (width - 32 - GAP * (COLUMNS - 1)) / COLUMNS;
|
||||
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.gifTitle")} onClose={onClose}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("storyEditor.gifSearch")}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
autoCorrect={false}
|
||||
style={styles.search}
|
||||
/>
|
||||
{isError && <Text style={styles.hint}>{t("storyEditor.gifError")}</Text>}
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(g) => g.id}
|
||||
numColumns={COLUMNS}
|
||||
columnWrapperStyle={{ gap: GAP }}
|
||||
contentContainerStyle={{ gap: GAP }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onEndReachedThreshold={0.6}
|
||||
onEndReached={() => {
|
||||
if (data?.nextOffset && !isFetching) setOffset(data.nextOffset);
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
|
||||
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay />
|
||||
</Pressable>
|
||||
)}
|
||||
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
|
||||
/>
|
||||
<View style={styles.attribution}>
|
||||
<Text style={styles.attributionText}>Powered by GIPHY</Text>
|
||||
</View>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 13 },
|
||||
attribution: { alignItems: "center", paddingTop: 4 },
|
||||
attributionText: { color: "#a3a3a3", fontFamily: fontFamily.bold, fontSize: 12, letterSpacing: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
/**
|
||||
* Tier zum Markieren suchen. Der Server liefert nur Tiere, die man laut ihrer
|
||||
* Einstellung „Wer darf die Follower sehen?“ markieren darf (und keine
|
||||
* blockierten) — darauf weist der Hinweis unten hin.
|
||||
*/
|
||||
export function MentionPicker({
|
||||
visible,
|
||||
petId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
petId: string;
|
||||
onSelect: (pet: { id: string; name: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), 300);
|
||||
|
||||
const { data: pets = [], isFetching } = useQuery({
|
||||
...trpc.stories.searchMentionable.queryOptions({ petId, query: debounced }),
|
||||
enabled: visible && debounced.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.mentionTitle")} onClose={onClose}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("storyEditor.mentionSearch")}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.search}
|
||||
/>
|
||||
{isFetching && <ActivityIndicator color="#f97316" />}
|
||||
<FlatList
|
||||
data={debounced ? pets : []}
|
||||
keyExtractor={(p) => p.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => onSelect({ id: item.id, name: item.name })} accessibilityRole="button">
|
||||
<PetAvatar name={item.name} avatarKey={item.avatarKey} size="sm" owner={item.owner} />
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
debounced && !isFetching ? <Text style={styles.hint}>{t("storyEditor.mentionNoResults")}</Text> : null
|
||||
}
|
||||
/>
|
||||
<Text style={styles.hint}>{t("storyEditor.mentionPrivacyHint")}</Text>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10 },
|
||||
name: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 15 },
|
||||
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 12, paddingVertical: 6 },
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { memo } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import Svg, { Circle, Path, Rect as SvgRect } from "react-native-svg";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { OVERLAY_SIZES, contrastTextColor, type StoryOverlay, type TextOverlay } from "../../lib/story-overlays";
|
||||
import { STORY_STICKERS, STICKER_OUTLINE, type StickerPart } from "../../lib/story-stickers";
|
||||
|
||||
/**
|
||||
* Sichtbarer Inhalt eines Story-Overlays, ohne Position/Drehung/Skalierung —
|
||||
* das übernehmen StoryOverlayLayer (Anzeige) bzw. EditableOverlay (Editor).
|
||||
* Alle Größen hängen nur von `baseWidth` (Breite der Medienfläche) ab, damit
|
||||
* es auf jedem Bildschirm und im Web (StoryOverlayLayer.tsx dort) gleich aussieht.
|
||||
*/
|
||||
export const OverlayContent = memo(function OverlayContent({
|
||||
overlay,
|
||||
baseWidth,
|
||||
mentionName,
|
||||
}: {
|
||||
overlay: StoryOverlay;
|
||||
baseWidth: number;
|
||||
mentionName?: string;
|
||||
}) {
|
||||
switch (overlay.kind) {
|
||||
case "text":
|
||||
return <TextContent overlay={overlay} baseWidth={baseWidth} />;
|
||||
case "mention":
|
||||
return <MentionChip name={mentionName ?? "…"} baseWidth={baseWidth} />;
|
||||
case "gif": {
|
||||
const width = OVERLAY_SIZES.gifWidth * baseWidth;
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: overlay.url }}
|
||||
style={{ width, height: (width * overlay.height) / overlay.width, borderRadius: baseWidth * 0.02 }}
|
||||
autoplay
|
||||
contentFit="cover"
|
||||
accessibilityLabel="GIF"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "sticker":
|
||||
return <StickerContent stickerId={overlay.stickerId} size={OVERLAY_SIZES.sticker * baseWidth} />;
|
||||
}
|
||||
});
|
||||
|
||||
function TextContent({ overlay, baseWidth }: { overlay: TextOverlay; baseWidth: number }) {
|
||||
const fontSize = OVERLAY_SIZES.textFont * baseWidth;
|
||||
const { textColor, background } = textAppearance(overlay);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
maxWidth: OVERLAY_SIZES.textMaxWidth * baseWidth,
|
||||
backgroundColor: background,
|
||||
borderRadius: fontSize * 0.3,
|
||||
paddingHorizontal: background ? fontSize * 0.4 : 0,
|
||||
paddingVertical: background ? fontSize * 0.18 : 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
fontSize,
|
||||
lineHeight: fontSize * 1.2,
|
||||
color: textColor,
|
||||
fontFamily: overlay.font === "bold" ? fontFamily.bold : fontFamily.regular,
|
||||
},
|
||||
!background && styles.textShadow,
|
||||
]}
|
||||
>
|
||||
{overlay.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Farben je Textstil — identische Regeln im Web (src/components/stories/StoryOverlayLayer.tsx). */
|
||||
export function textAppearance(overlay: Pick<TextOverlay, "color" | "style">): { textColor: string; background?: string } {
|
||||
switch (overlay.style) {
|
||||
case "box":
|
||||
return { textColor: contrastTextColor(overlay.color), background: overlay.color };
|
||||
case "soft":
|
||||
return {
|
||||
textColor: overlay.color,
|
||||
background: overlay.color === "#171717" ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.55)",
|
||||
};
|
||||
default:
|
||||
return { textColor: overlay.color };
|
||||
}
|
||||
}
|
||||
|
||||
export function MentionChip({ name, baseWidth }: { name: string; baseWidth: number }) {
|
||||
const fontSize = OVERLAY_SIZES.mentionFont * baseWidth;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: fontSize,
|
||||
paddingHorizontal: fontSize * 0.6,
|
||||
paddingVertical: fontSize * 0.25,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize, lineHeight: fontSize * 1.25, color: "#f97316", fontFamily: fontFamily.bold }} numberOfLines={1}>
|
||||
@{name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function StickerContent({ stickerId, size }: { stickerId: string; size: number }) {
|
||||
const sticker = STORY_STICKERS.find((s) => s.id === stickerId);
|
||||
if (!sticker) return null;
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="-6 -6 112 112" accessibilityLabel={sticker.label}>
|
||||
{sticker.parts.map((part, i) => (
|
||||
<StickerShape key={`o${i}`} part={part} fill={STICKER_OUTLINE.color} stroke={STICKER_OUTLINE.color} strokeWidth={STICKER_OUTLINE.width} />
|
||||
))}
|
||||
{sticker.parts.map((part, i) => (
|
||||
<StickerShape key={`f${i}`} part={part} fill={part.fill} />
|
||||
))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerShape({ part, fill, stroke, strokeWidth }: { part: StickerPart; fill: string; stroke?: string; strokeWidth?: number }) {
|
||||
const strokeProps = stroke ? { stroke, strokeWidth, strokeLinejoin: "round" as const } : {};
|
||||
switch (part.type) {
|
||||
case "circle":
|
||||
return <Circle cx={part.cx} cy={part.cy} r={part.r} fill={fill} {...strokeProps} />;
|
||||
case "rect":
|
||||
return <SvgRect x={part.x} y={part.y} width={part.width} height={part.height} rx={part.rx} fill={fill} {...strokeProps} />;
|
||||
case "path":
|
||||
return <Path d={part.d} fill={fill} {...strokeProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: { textAlign: "center" },
|
||||
textShadow: { textShadowColor: "rgba(0,0,0,0.45)", textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 6 },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
|
||||
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
|
||||
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
|
||||
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button">
|
||||
<X color="#ffffff" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, justifyContent: "flex-end" },
|
||||
dismiss: { flex: 1 },
|
||||
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
title: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 17 },
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { STORY_STICKERS } from "../../lib/story-stickers";
|
||||
import { StickerContent } from "./OverlayContent";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
export function StickerPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (stickerId: string) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.stickerTitle")} onClose={onClose}>
|
||||
<View style={styles.grid}>
|
||||
{STORY_STICKERS.map((sticker) => (
|
||||
<Pressable
|
||||
key={sticker.id}
|
||||
style={styles.cell}
|
||||
onPress={() => onSelect(sticker.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={sticker.label}
|
||||
>
|
||||
<StickerContent stickerId={sticker.id} size={64} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, paddingBottom: 8 },
|
||||
cell: { width: "23%", aspectRatio: 1, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: "#2a2a2a" },
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { STORY_BACKGROUNDS } from "../../lib/story-overlays";
|
||||
|
||||
/**
|
||||
* Farbverlauf einer Text-Story. Über react-native-svg statt expo-linear-gradient:
|
||||
* letzteres wäre ein neues natives Modul (= Store-Build), svg ist schon drin.
|
||||
*/
|
||||
export function StoryBackground({ backgroundKey, width, height, radius = 0 }: { backgroundKey: string; width: number; height: number; radius?: number }) {
|
||||
const [from, to] = STORY_BACKGROUNDS[backgroundKey] ?? STORY_BACKGROUNDS.sunset;
|
||||
const id = `bg-${backgroundKey}`;
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
<Defs>
|
||||
<LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
|
||||
<Stop offset="0" stopColor={from} />
|
||||
<Stop offset="1" stopColor={to} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect x={0} y={0} width={width} height={height} rx={radius} fill={`url(#${id})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Image as RNImage, ActivityIndicator, Alert, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { AtSign, Smile, Trash2, Type, X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import {
|
||||
MAX_GIFS,
|
||||
MAX_MENTIONS,
|
||||
MAX_OVERLAYS,
|
||||
STORY_BACKGROUND_KEYS,
|
||||
STORY_BACKGROUNDS,
|
||||
TEXT_STORY_ASPECT,
|
||||
containRect,
|
||||
type StoryOverlay,
|
||||
type TextOverlay,
|
||||
} from "../../lib/story-overlays";
|
||||
import { EditableOverlay } from "./EditableOverlay";
|
||||
import { StoryBackground } from "./StoryBackground";
|
||||
import { TextEditorModal, type TextDraft } from "./TextEditorModal";
|
||||
import { MentionPicker } from "./MentionPicker";
|
||||
import { GifPicker } from "./GifPicker";
|
||||
import { StickerPicker } from "./StickerPicker";
|
||||
|
||||
export type EditorMedia =
|
||||
| { kind: "image"; uri: string; width: number; height: number }
|
||||
| { kind: "video"; uri: string; width: number; height: number; mimeType?: string }
|
||||
| { kind: "text" };
|
||||
|
||||
export type EditorResult = { overlays: StoryOverlay[]; background?: string; mentionNames: Record<string, string> };
|
||||
|
||||
type Picker = "text" | "mention" | "gif" | "sticker" | null;
|
||||
|
||||
const TRASH_ZONE_HEIGHT = 120;
|
||||
|
||||
/**
|
||||
* Story-Editor (feature/story-editor): Foto, Video oder Farbverlauf als Fläche,
|
||||
* darauf Text, @-Markierungen, GIFs und Sticker. Positionen werden relativ zur
|
||||
* Medienfläche gespeichert (lib/story-overlays.ts), damit sie im Viewer — App
|
||||
* und Web — an derselben Stelle sitzen.
|
||||
*/
|
||||
export function StoryEditor({
|
||||
media,
|
||||
petId,
|
||||
isSubmitting,
|
||||
submitLabel,
|
||||
progress,
|
||||
toolsEnabled = true,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
media: EditorMedia;
|
||||
/** Notschalter aus → nur Vorschau + Teilen, keine Werkzeuge (wie vor dem Editor). */
|
||||
toolsEnabled?: boolean;
|
||||
petId: string;
|
||||
isSubmitting: boolean;
|
||||
submitLabel: string;
|
||||
/** Upload-Fortschritt/Status unter dem Teilen-Knopf (Video). */
|
||||
progress?: React.ReactNode;
|
||||
onSubmit: (result: EditorResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [canvas, setCanvas] = useState({ width: 0, height: 0 });
|
||||
const [overlays, setOverlays] = useState<StoryOverlay[]>([]);
|
||||
const [background, setBackground] = useState<string>(STORY_BACKGROUND_KEYS[0]);
|
||||
const [mentionNames, setMentionNames] = useState<Record<string, string>>({});
|
||||
const [picker, setPicker] = useState<Picker>(media.kind === "text" ? "text" : null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [overTrash, setOverTrash] = useState(false);
|
||||
const canvasTopRef = useRef(0);
|
||||
const canvasRef = useRef<View>(null);
|
||||
|
||||
const mediaSize = media.kind === "text" ? TEXT_STORY_ASPECT : { width: media.width, height: media.height };
|
||||
const rect = useMemo(
|
||||
() => containRect(canvas.width, canvas.height, mediaSize.width, mediaSize.height),
|
||||
[canvas.width, canvas.height, mediaSize.width, mediaSize.height]
|
||||
);
|
||||
|
||||
const onCanvasLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setCanvas({ width, height });
|
||||
canvasRef.current?.measureInWindow((_x, y) => {
|
||||
canvasTopRef.current = y;
|
||||
});
|
||||
};
|
||||
|
||||
const trashTop = () => canvasTopRef.current + canvas.height - TRASH_ZONE_HEIGHT;
|
||||
const isOverTrash = useCallback((pageY: number) => pageY >= trashTop(), [canvas.height]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const gifCount = overlays.filter((o) => o.kind === "gif").length;
|
||||
const mentionCount = new Set(overlays.flatMap((o) => (o.kind === "mention" ? [o.petId] : []))).size;
|
||||
const isFull = overlays.length >= MAX_OVERLAYS;
|
||||
|
||||
const add = (overlay: StoryOverlay) => {
|
||||
if (isFull) {
|
||||
Alert.alert(t("storyEditor.limitOverlays", { max: MAX_OVERLAYS }));
|
||||
return;
|
||||
}
|
||||
setOverlays((list) => [...list, overlay]);
|
||||
};
|
||||
|
||||
// Neue Overlays erscheinen leicht versetzt in der Mitte, damit sie sich nicht genau überdecken.
|
||||
const spawnPosition = () => {
|
||||
const offset = (overlays.length % 5) * 0.04;
|
||||
return { x: 0.5, y: 0.42 + offset, scale: 1, rotation: 0 };
|
||||
};
|
||||
|
||||
const handleTextDone = (draft: TextDraft) => {
|
||||
const text = draft.text.trim();
|
||||
if (editingIndex !== null) {
|
||||
setOverlays((list) =>
|
||||
text
|
||||
? list.map((o, i) => (i === editingIndex && o.kind === "text" ? { ...o, ...draft, text } : o))
|
||||
: list.filter((_, i) => i !== editingIndex)
|
||||
);
|
||||
} else if (text) {
|
||||
add({ kind: "text", ...spawnPosition(), ...draft, text });
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
};
|
||||
|
||||
const editingText = editingIndex !== null && overlays[editingIndex]?.kind === "text" ? (overlays[editingIndex] as TextOverlay) : undefined;
|
||||
const hasText = overlays.some((o) => o.kind === "text");
|
||||
const canSubmit = !isSubmitting && rect.width > 0 && (media.kind !== "text" || hasText);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View ref={canvasRef} style={styles.canvas} onLayout={onCanvasLayout}>
|
||||
{rect.width > 0 && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}>
|
||||
{media.kind === "text" ? (
|
||||
<StoryBackground backgroundKey={background} width={rect.width} height={rect.height} />
|
||||
) : media.kind === "video" ? (
|
||||
<EditorVideo uri={media.uri} />
|
||||
) : (
|
||||
<RNImage source={{ uri: media.uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{overlays.map((overlay, index) => (
|
||||
<EditableOverlay
|
||||
key={index}
|
||||
overlay={overlay}
|
||||
rect={rect}
|
||||
mentionName={overlay.kind === "mention" ? mentionNames[overlay.petId] : undefined}
|
||||
onChange={(next) => setOverlays((list) => list.map((o, i) => (i === index ? next : o)))}
|
||||
onTap={() => {
|
||||
if (overlay.kind === "text") {
|
||||
setEditingIndex(index);
|
||||
setPicker("text");
|
||||
}
|
||||
}}
|
||||
onDragStateChange={(isDragging, pageY) => {
|
||||
setDragging(isDragging);
|
||||
setOverTrash(isDragging && isOverTrash(pageY));
|
||||
}}
|
||||
isOverTrash={isOverTrash}
|
||||
onDelete={() => setOverlays((list) => list.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{toolsEnabled && overlays.length === 0 && media.kind !== "text" && rect.width > 0 && (
|
||||
<View pointerEvents="none" style={[styles.hintWrap, { top: rect.y + rect.height / 2 - 20 }]}>
|
||||
<Text style={styles.hint}>{t("storyEditor.emptyHint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<View pointerEvents="none" style={[styles.trashZone, { height: TRASH_ZONE_HEIGHT }]}>
|
||||
<View style={[styles.trashCircle, overTrash && styles.trashCircleActive]}>
|
||||
<Trash2 color="#ffffff" size={24} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.topBar, { top: insets.top + 8 }]}>
|
||||
<ToolButton onPress={onCancel} label={t("common.cancel")}>
|
||||
<X color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
{toolsEnabled && <View style={styles.tools}>
|
||||
{media.kind === "text" && (
|
||||
<ToolButton
|
||||
label={t("storyEditor.background")}
|
||||
onPress={() => setBackground((bg) => STORY_BACKGROUND_KEYS[(STORY_BACKGROUND_KEYS.indexOf(bg) + 1) % STORY_BACKGROUND_KEYS.length])}
|
||||
>
|
||||
<View style={[styles.swatch, { backgroundColor: STORY_BACKGROUNDS[background][0], borderColor: STORY_BACKGROUNDS[background][1] }]} />
|
||||
</ToolButton>
|
||||
)}
|
||||
<ToolButton label={t("storyEditor.addText")} onPress={() => setPicker("text")}>
|
||||
<Type color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addMention")}
|
||||
onPress={() => (mentionCount >= MAX_MENTIONS ? Alert.alert(t("storyEditor.limitMentions", { max: MAX_MENTIONS })) : setPicker("mention"))}
|
||||
>
|
||||
<AtSign color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addGif")}
|
||||
onPress={() => (gifCount >= MAX_GIFS ? Alert.alert(t("storyEditor.limitGifs", { max: MAX_GIFS })) : setPicker("gif"))}
|
||||
>
|
||||
<Text style={styles.gifLabel}>GIF</Text>
|
||||
</ToolButton>
|
||||
<ToolButton label={t("storyEditor.addSticker")} onPress={() => setPicker("sticker")}>
|
||||
<Smile color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
</View>}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<Pressable
|
||||
style={[styles.shareButton, !canSubmit && styles.shareButtonDisabled]}
|
||||
disabled={!canSubmit}
|
||||
onPress={() => onSubmit({ overlays, background: media.kind === "text" ? background : undefined, mentionNames })}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.shareText}>{submitLabel}</Text>}
|
||||
</Pressable>
|
||||
{progress}
|
||||
</View>
|
||||
|
||||
<TextEditorModal
|
||||
visible={picker === "text"}
|
||||
initial={editingText ? { text: editingText.text, color: editingText.color, style: editingText.style, font: editingText.font } : undefined}
|
||||
onDone={handleTextDone}
|
||||
onCancel={() => {
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
if (media.kind === "text" && !hasText && editingIndex === null && overlays.length === 0) onCancel();
|
||||
}}
|
||||
/>
|
||||
<MentionPicker
|
||||
visible={picker === "mention"}
|
||||
petId={petId}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(pet) => {
|
||||
setMentionNames((names) => ({ ...names, [pet.id]: pet.name }));
|
||||
if (!overlays.some((o) => o.kind === "mention" && o.petId === pet.id)) add({ kind: "mention", ...spawnPosition(), petId: pet.id });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<GifPicker
|
||||
visible={picker === "gif"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(gif) => {
|
||||
add({ kind: "gif", ...spawnPosition(), gifId: gif.id, url: gif.url, width: gif.width, height: gif.height });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<StickerPicker
|
||||
visible={picker === "sticker"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(stickerId) => {
|
||||
add({ kind: "sticker", ...spawnPosition(), stickerId });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({ label, onPress, children }: { label: string; onPress: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable style={styles.toolButton} onPress={onPress} hitSlop={6} accessibilityRole="button" accessibilityLabel={label}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stumm in Schleife — eigene Komponente, damit useVideoPlayer nicht bedingt aufgerufen wird. */
|
||||
function EditorVideo({ uri }: { uri: string }) {
|
||||
const player = useVideoPlayer(uri, (p) => {
|
||||
p.loop = true;
|
||||
p.muted = true;
|
||||
p.play();
|
||||
});
|
||||
return <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: "#111111" },
|
||||
canvas: { flex: 1 },
|
||||
topBar: { position: "absolute", left: 12, right: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
tools: { flexDirection: "row", gap: 8 },
|
||||
toolButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: "rgba(0,0,0,0.45)", alignItems: "center", justifyContent: "center" },
|
||||
gifLabel: { color: "#ffffff", fontFamily: fontFamily.extraBold, fontSize: 13, letterSpacing: 0.5 },
|
||||
swatch: { width: 22, height: 22, borderRadius: 11, borderWidth: 3 },
|
||||
bottomBar: { paddingHorizontal: 16, paddingTop: 12, gap: 8, backgroundColor: "#111111" },
|
||||
shareButton: { height: 48, borderRadius: 24, backgroundColor: "#f97316", alignItems: "center", justifyContent: "center" },
|
||||
shareButtonDisabled: { opacity: 0.5 },
|
||||
shareText: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 16 },
|
||||
hintWrap: { position: "absolute", left: 24, right: 24, alignItems: "center" },
|
||||
hint: {
|
||||
color: "#ffffff",
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0,0,0,0.6)",
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
trashZone: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
||||
trashCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: "rgba(0,0,0,0.55)", alignItems: "center", justifyContent: "center", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
trashCircleActive: { backgroundColor: "#dc2626", transform: [{ scale: 1.2 }] },
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { overlayCenter, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
type MentionedPet = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Anzeige-Ebene im Story-Viewer: legt die Overlays auf die Medienfläche `rect`
|
||||
* (gleiches Koordinatensystem wie im Editor). Nur Markierungen sind antippbar
|
||||
* (→ Tierprofil), alles andere lässt Berührungen zu den Weiter/Zurück-Zonen durch.
|
||||
*/
|
||||
export function StoryOverlayLayer({
|
||||
overlays,
|
||||
rect,
|
||||
mentionedPets,
|
||||
onPressMention,
|
||||
}: {
|
||||
overlays: readonly StoryOverlay[];
|
||||
rect: Rect;
|
||||
mentionedPets: readonly MentionedPet[];
|
||||
onPressMention?: (petId: string) => void;
|
||||
}) {
|
||||
if (!overlays.length || rect.width <= 0) return null;
|
||||
const names = new Map(mentionedPets.map((p) => [p.id, p.name]));
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
||||
{overlays.map((overlay, i) => {
|
||||
const content = (
|
||||
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={overlay.kind === "mention" ? names.get(overlay.petId) : undefined} />
|
||||
);
|
||||
return (
|
||||
<PositionedOverlay key={i} overlay={overlay} rect={rect} interactive={overlay.kind === "mention" && !!onPressMention}>
|
||||
{overlay.kind === "mention" && onPressMention ? (
|
||||
<Pressable onPress={() => onPressMention(overlay.petId)} hitSlop={8} accessibilityRole="link">
|
||||
{content}
|
||||
</Pressable>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</PositionedOverlay>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentriert den Inhalt auf (x, y) der Medienfläche, ohne ihn messen zu müssen:
|
||||
* ein großes, unsichtbares Quadrat um den Mittelpunkt mit zentriertem Inhalt.
|
||||
* Drehung und Skalierung wirken um die Mitte — wie im Web (transform-origin center).
|
||||
*/
|
||||
export function PositionedOverlay({
|
||||
overlay,
|
||||
rect,
|
||||
interactive,
|
||||
children,
|
||||
}: {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
interactive: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const box = rect.width * 2;
|
||||
const { left, top } = overlayCenter(rect, overlay);
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{ position: "absolute", left: left - box / 2, top: top - box / 2, width: box, height: box, alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<View
|
||||
pointerEvents={interactive ? "auto" : "none"}
|
||||
style={{ transform: [{ rotate: `${overlay.rotation}deg` }, { scale: overlay.scale }] }}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
|
||||
import { textAppearance } from "./OverlayContent";
|
||||
|
||||
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
|
||||
const DEFAULT_DRAFT: TextDraft = { text: "", color: "#ffffff", style: "plain", font: "bold" };
|
||||
|
||||
/**
|
||||
* Vollbild-Texteingabe über dem Editor (wie Instagram): Text tippen, Farbe
|
||||
* (8 Farben), Stil (schlicht → Box → weich) und Schrift (fett/normal) wählen.
|
||||
* Leerer Text beim Fertig-Drücken = Overlay entfernen bzw. nicht anlegen.
|
||||
*/
|
||||
export function TextEditorModal({
|
||||
visible,
|
||||
initial,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
initial?: TextDraft;
|
||||
onDone: (draft: TextDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
|
||||
|
||||
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Zurücksetzen beim Öffnen ist hier gewollt
|
||||
if (visible) setDraft(initial ?? DEFAULT_DRAFT);
|
||||
}, [visible, initial]);
|
||||
|
||||
const { textColor, background } = textAppearance(draft);
|
||||
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={styles.topButton}>{t("common.cancel")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.topTools}>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, style: nextStyle }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textStyle")}
|
||||
>
|
||||
<Text style={styles.toolButtonText}>{t(`storyEditor.style.${draft.style}`)}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, font: d.font === "bold" ? "regular" : "bold" }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textFont")}
|
||||
>
|
||||
<Text style={[styles.toolButtonText, { fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular }]}>Aa</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => onDone(draft)} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={[styles.topButton, styles.done]}>{t("common.done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.center}>
|
||||
<View style={[styles.preview, background ? { backgroundColor: background } : null]}>
|
||||
<TextInput
|
||||
value={draft.text}
|
||||
onChangeText={(text) => setDraft((d) => ({ ...d, text }))}
|
||||
autoFocus
|
||||
multiline
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
placeholder={t("storyEditor.textPlaceholder")}
|
||||
placeholderTextColor="rgba(255,255,255,0.6)"
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: textColor, fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.colors}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => setDraft((d) => ({ ...d, color }))}
|
||||
style={[styles.colorDot, { backgroundColor: color }, draft.color === color && styles.colorDotActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={color}
|
||||
accessibilityState={{ selected: draft.color === color }}
|
||||
hitSlop={4}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
|
||||
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
|
||||
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
|
||||
done: { color: "#f97316" },
|
||||
topTools: { flexDirection: "row", gap: 10 },
|
||||
toolButton: { borderWidth: 1.5, borderColor: "#ffffff", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 },
|
||||
toolButtonText: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 14 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24 },
|
||||
preview: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, maxWidth: "100%" },
|
||||
input: { fontSize: 28, lineHeight: 34, textAlign: "center", minWidth: 120, textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 4 },
|
||||
colors: { flexDirection: "row", justifyContent: "center", gap: 12, paddingVertical: 8 },
|
||||
colorDot: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
colorDotActive: { borderColor: "#ffffff", borderWidth: 3, transform: [{ scale: 1.15 }] },
|
||||
});
|
||||
@@ -210,7 +210,10 @@
|
||||
"petSuggestion": "könnte dir gefallen!",
|
||||
"empty": "Keine Benachrichtigungen.",
|
||||
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!"
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
|
||||
"storyMention": "hat dich in einer Story markiert.",
|
||||
"storyComment": "hat deine Story kommentiert.",
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -449,9 +452,37 @@
|
||||
"whoCanSeeFollowers": "Wer darf die Follower von {{name}} sehen?",
|
||||
"onlyMyFollowers": "Nur meine Follower",
|
||||
"onlyMe": "Nur ich",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen.",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen und {{name}} in Storys markieren.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen und {{name}} in Storys markieren.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen — markieren in Storys kann {{name}} niemand.",
|
||||
"saveFailed": "Privatsphäre-Einstellung konnte nicht gespeichert werden"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text-Story",
|
||||
"addText": "Text hinzufügen",
|
||||
"addMention": "Tier markieren",
|
||||
"addGif": "GIF hinzufügen",
|
||||
"addSticker": "Sticker hinzufügen",
|
||||
"background": "Hintergrund wechseln",
|
||||
"emptyHint": "Füge Text, @Markierungen, GIFs oder Sticker hinzu — oder teile direkt.",
|
||||
"textPlaceholder": "Schreib etwas …",
|
||||
"textStyle": "Textstil",
|
||||
"textFont": "Schrift",
|
||||
"style": {
|
||||
"plain": "Schlicht",
|
||||
"box": "Box",
|
||||
"soft": "Weich"
|
||||
},
|
||||
"mentionTitle": "Tier markieren",
|
||||
"mentionSearch": "Tiername suchen",
|
||||
"mentionNoResults": "Kein Tier gefunden, das du markieren darfst.",
|
||||
"mentionPrivacyHint": "Tiere, deren Follower nur für ihre Follower oder niemanden sichtbar sind, kannst du nur markieren, wenn du ihnen folgst bzw. gar nicht.",
|
||||
"gifTitle": "GIF auswählen",
|
||||
"gifSearch": "GIPHY durchsuchen",
|
||||
"gifError": "Die GIF-Suche ist gerade nicht erreichbar.",
|
||||
"stickerTitle": "Sticker",
|
||||
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
|
||||
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
|
||||
"limitGifs": "Höchstens {{max}} GIFs pro Story."
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,10 @@
|
||||
"petSuggestion": "might be a good match!",
|
||||
"empty": "No notifications.",
|
||||
"referralJoined": "joined PawFeed through your invite.",
|
||||
"referralRankUp": "You reached a new referral rank!"
|
||||
"referralRankUp": "You reached a new referral rank!",
|
||||
"storyMention": "tagged you in a story.",
|
||||
"storyComment": "commented on your story.",
|
||||
"storyReaction": "gave your story a paw."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -449,9 +452,37 @@
|
||||
"whoCanSeeFollowers": "Who can see {{name}}'s followers?",
|
||||
"onlyMyFollowers": "Only my followers",
|
||||
"onlyMe": "Only me",
|
||||
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists.",
|
||||
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists.",
|
||||
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists and tag {{name}} in stories.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists and tag {{name}} in stories.",
|
||||
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists — nobody can tag {{name}} in stories.",
|
||||
"saveFailed": "Failed to save privacy setting"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text story",
|
||||
"addText": "Add text",
|
||||
"addMention": "Tag a pet",
|
||||
"addGif": "Add GIF",
|
||||
"addSticker": "Add sticker",
|
||||
"background": "Change background",
|
||||
"emptyHint": "Add text, @tags, GIFs or stickers — or share right away.",
|
||||
"textPlaceholder": "Write something …",
|
||||
"textStyle": "Text style",
|
||||
"textFont": "Font",
|
||||
"style": {
|
||||
"plain": "Plain",
|
||||
"box": "Box",
|
||||
"soft": "Soft"
|
||||
},
|
||||
"mentionTitle": "Tag a pet",
|
||||
"mentionSearch": "Search pet name",
|
||||
"mentionNoResults": "No pet found that you're allowed to tag.",
|
||||
"mentionPrivacyHint": "Pets whose followers are visible only to their followers or nobody can only be tagged if you follow them, or not at all.",
|
||||
"gifTitle": "Choose a GIF",
|
||||
"gifSearch": "Search GIPHY",
|
||||
"gifError": "GIF search is unavailable right now.",
|
||||
"stickerTitle": "Stickers",
|
||||
"limitOverlays": "At most {{max}} items per story.",
|
||||
"limitMentions": "Tag at most {{max}} pets per story.",
|
||||
"limitGifs": "At most {{max}} GIFs per story."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user