refactor(mobile): Audit O1 – doppelte Helfer zusammengelegt (M1–M3, M5–M7)
- M1: typography.caption statt 44× label ohne Großschreibung; sectionTitleStyle.ts entfällt - M2: uploadPickedImage() für verkleinern → presign → PUT (Post, Meilenstein, Story, Avatar) - M3: uploadImageToSupabase in uploadFileToUrl aufgegangen - M5: usePawToggle für PawButton + StoryPawButton (Beitrags-Pfote zählt jetzt sofort mit) - M6: healthSectionStyles für Impfung, Tierarzt, Gewicht - M7: unbenutzten Typ PostDetail entfernt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c6c95955e
commit
9f34f0e5c3
44 files changed
+162
-225
No files matched your search
@@ -184,13 +184,13 @@ function makeStyles(colors: ColorTokens) {
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
||||
errorText: { ...typography.body, color: colors.alertRed },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
followers: { ...typography.caption },
|
||||
footer: { marginVertical: 16 },
|
||||
filters: { gap: 10, marginBottom: 12 },
|
||||
speciesTabs: { gap: 8 },
|
||||
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||
tabActive: { backgroundColor: colors.marigoldOrange },
|
||||
tabText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
tabText: { ...typography.caption, color: colors.ink },
|
||||
tabTextActive: { color: colors.pureWhite },
|
||||
breedFilter: { width: 220 },
|
||||
});
|
||||
|
||||
@@ -229,7 +229,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 },
|
||||
rowUnread: { backgroundColor: colors.marigoldTint },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
timestamp: { ...typography.caption },
|
||||
footer: { marginVertical: 16 },
|
||||
selfIcon: {
|
||||
width: 40,
|
||||
|
||||
@@ -205,7 +205,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
fact: { flexDirection: "row", gap: 10 },
|
||||
factIcon: { paddingTop: 2 },
|
||||
flex: { flex: 1 },
|
||||
factLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
factLabel: { ...typography.caption },
|
||||
factValue: { ...typography.body },
|
||||
contactCard: { gap: 8, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, backgroundColor: colors.alertRedTint },
|
||||
contactHeading: { ...typography.title, fontSize: 16 },
|
||||
@@ -221,7 +221,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
contactButtonText: { ...typography.body, fontFamily: typography.title.fontFamily, flexShrink: 1 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.caption, marginTop: 4 },
|
||||
section: { gap: 8 },
|
||||
sectionTitle: { ...typography.title, fontSize: 15 },
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
|
||||
@@ -132,7 +132,7 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) {
|
||||
bubbleOwn: { backgroundColor: colors.marigoldOrange, borderBottomRightRadius: 4 },
|
||||
bubbleText: { ...typography.body },
|
||||
bubbleTextOwn: { color: colors.pureWhite },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10 },
|
||||
timestamp: { ...typography.caption, fontSize: 10 },
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
nameRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
preview: { ...typography.body, color: colors.quietGray },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
timestamp: { ...typography.caption },
|
||||
unreadDot: {
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -8,8 +8,7 @@ import { ImagePlus } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadPickedImage } from "../../src/lib/upload";
|
||||
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
@@ -62,13 +61,7 @@ export default function NewMilestoneScreen() {
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
if (asset) {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file }));
|
||||
imageKey = key;
|
||||
}
|
||||
createMutation.mutate({
|
||||
@@ -142,7 +135,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.caption },
|
||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
@@ -168,7 +161,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
preview: { width: "100%", height: 220, borderRadius: radii.md, backgroundColor: colors.hairlineGray },
|
||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", marginTop: 6 },
|
||||
previewHint: { ...typography.caption, textAlign: "center", marginTop: 6 },
|
||||
notes: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 12 },
|
||||
label: { ...typography.caption, marginTop: 12 },
|
||||
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
||||
deleteRow: {
|
||||
|
||||
@@ -291,10 +291,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
statCount: { ...typography.title, fontSize: 18 },
|
||||
statLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
statLabel: { ...typography.caption },
|
||||
iconRow: { flexDirection: "row", gap: 20, marginTop: 14 },
|
||||
iconButton: { alignItems: "center", gap: 4 },
|
||||
iconButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
iconButtonText: { ...typography.caption, fontSize: 11 },
|
||||
section: { ...typography.body, alignSelf: "stretch", marginTop: 12 },
|
||||
sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 },
|
||||
divider: { height: 1, backgroundColor: colors.hairlineGray, alignSelf: "stretch", marginTop: 16 },
|
||||
|
||||
@@ -104,7 +104,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowDate: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
rowDate: { ...typography.caption },
|
||||
rowNotes: { ...typography.body, fontSize: 13 },
|
||||
thumb: { width: 48, height: 48, borderRadius: radii.md },
|
||||
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
|
||||
|
||||
@@ -8,8 +8,7 @@ import { X, ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadPickedImage } from "../../src/lib/upload";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
@@ -64,13 +63,7 @@ export default function NewPostScreen() {
|
||||
const uploadAsset = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
||||
try {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId!,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId!, ...file }));
|
||||
setImages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], status: "done", key };
|
||||
|
||||
@@ -187,8 +187,8 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) {
|
||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||
bodyText: { ...typography.body },
|
||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
timestamp: { ...typography.caption, fontSize: 11 },
|
||||
pendingLabel: { ...typography.caption, fontSize: 11 },
|
||||
deleteButton: { alignSelf: "flex-start", padding: 4 },
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -248,7 +248,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 },
|
||||
tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||
tabButtonActive: { backgroundColor: colors.marigoldOrange },
|
||||
tabButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
tabButtonText: { ...typography.caption, color: colors.ink },
|
||||
tabButtonTextActive: { color: colors.pureWhite },
|
||||
list: { padding: 16, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
@@ -258,7 +258,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
|
||||
petRowText: { flex: 1 },
|
||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
petSpecies: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
petSpecies: { ...typography.caption },
|
||||
ownerBlock: { marginBottom: 16 },
|
||||
ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 },
|
||||
tagGrid: { padding: 2 },
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadFileToUrl, uploadPickedImage } from "../../src/lib/upload";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
|
||||
@@ -95,17 +94,11 @@ export default function StoryEditorScreen() {
|
||||
}
|
||||
|
||||
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);
|
||||
const uploaded = await uploadPickedImage(media, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file }));
|
||||
const size = await imageSize(uploaded.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
storageKey: uploaded.key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
|
||||
@@ -12,4 +12,3 @@ export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][nu
|
||||
export type ChatMessage = RouterOutputs["messages"]["list"]["messages"][number];
|
||||
export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number];
|
||||
export type TrendingPost = RouterOutputs["explore"]["getTrending"]["posts"][number];
|
||||
export type PostDetail = RouterOutputs["posts"]["getById"];
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, StyleSheet } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
import { usePawToggle } from "../hooks/usePawToggle";
|
||||
import { colors, fontFamily } from "../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
@@ -16,37 +16,12 @@ type Props = {
|
||||
* `false` on mount — the web app's feed response doesn't tell the client
|
||||
* whether the viewer already reacted (postInclude has no reactions relation),
|
||||
* an acknowledged simplification on web that this mirrors rather than fixes.
|
||||
* Same optimistic-update shape: flip locally in onMutate, reconcile the exact
|
||||
* count from the server's authoritative `reacted` in onSuccess, roll back in
|
||||
* onError.
|
||||
* Optimistic toggle via usePawToggle (shared with StoryPawButton).
|
||||
*/
|
||||
export function PawButton({ postId, petId, initialCount }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(false);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggleMutation = useMutation(
|
||||
trpc.reactions.toggle.mutationOptions({
|
||||
onMutate: () => {
|
||||
const prevReacted = reacted;
|
||||
const prevCount = count;
|
||||
setReacted(!reacted);
|
||||
return { prevReacted, prevCount };
|
||||
},
|
||||
onSuccess: (data, _vars, context) => {
|
||||
setReacted(data.reacted);
|
||||
if (context) {
|
||||
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
|
||||
}
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context) {
|
||||
setReacted(context.prevReacted);
|
||||
setCount(context.prevCount);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
const paw = usePawToggle(false, initialCount);
|
||||
const toggleMutation = useMutation(trpc.reactions.toggle.mutationOptions(paw.callbacks));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -56,10 +31,10 @@ export function PawButton({ postId, petId, initialCount }: Props) {
|
||||
>
|
||||
<PawPrint
|
||||
size={20}
|
||||
color={reacted ? colors.marigoldOrange : colors.quietGray}
|
||||
fill={reacted ? colors.marigoldOrange : "transparent"}
|
||||
color={paw.reacted ? colors.marigoldOrange : colors.quietGray}
|
||||
fill={paw.reacted ? colors.marigoldOrange : "transparent"}
|
||||
/>
|
||||
<Text style={styles.count}>{count}</Text>
|
||||
<Text style={styles.count}>{paw.count}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,8 +312,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderRadius: radii.sm,
|
||||
backgroundColor: colors.marigoldTint,
|
||||
},
|
||||
trendingBadgeText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10, color: colors.marigoldDeep },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
trendingBadgeText: { ...typography.caption, fontSize: 10, color: colors.marigoldDeep },
|
||||
timestamp: { ...typography.caption },
|
||||
caption: { ...typography.body },
|
||||
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
|
||||
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
|
||||
@@ -81,7 +81,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
card: { gap: 8 },
|
||||
attribution: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
attributionText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
attributionText: { ...typography.caption, color: colors.quietGray },
|
||||
header: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
caption: { ...typography.body },
|
||||
|
||||
@@ -96,5 +96,5 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.pureWhite },
|
||||
label: { ...typography.caption, color: colors.pureWhite },
|
||||
});
|
||||
@@ -94,7 +94,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
request: { gap: 8 },
|
||||
requestRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
body: { ...typography.body, flex: 1 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -169,7 +169,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
hint: { ...typography.caption },
|
||||
body: { ...typography.body },
|
||||
code: {
|
||||
...typography.title,
|
||||
|
||||
@@ -147,7 +147,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
privacyLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 20 },
|
||||
privacyLink: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 20 },
|
||||
ctaRow: { flexDirection: "row", gap: 10 },
|
||||
ctaButton: { flex: 1 },
|
||||
manageBox: {
|
||||
@@ -163,7 +163,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
manageText: { flex: 1 },
|
||||
manageTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
manageDesc: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 2 },
|
||||
manageDesc: { ...typography.caption, marginTop: 2 },
|
||||
revokeLink: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -143,21 +144,14 @@ export function VaccineSection({ petId }: { petId: string }) {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
overdue: { color: colors.alertRed },
|
||||
presetRow: { flexDirection: "row", gap: 8 },
|
||||
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
|
||||
presetDisabled: { opacity: 0.4 },
|
||||
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
presetText: { ...typography.caption, color: colors.ink },
|
||||
hint: { ...typography.caption, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -112,16 +113,9 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
@@ -193,10 +193,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||
eyebrow: { ...typography.label },
|
||||
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
|
||||
readoutDate: { ...typography.caption, fontSize: 12 },
|
||||
deltaChip: { alignItems: "flex-end" },
|
||||
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
deltaCaption: { ...typography.caption, fontSize: 11 },
|
||||
plot: { height: CHART_HEIGHT },
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { WeightChart } from "./WeightChart";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import type { ColorTokens } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
/** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
|
||||
@@ -106,16 +107,9 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
row: { ...base.row, alignItems: "center" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StyleSheet } from "react-native";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/** Shared chrome of the health sections (vaccines, vet visits, weight): header, form, list rows. */
|
||||
export function healthSectionStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -266,7 +266,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
teaser: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 8 },
|
||||
teaserText: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
teaserText: { ...typography.caption },
|
||||
teaserButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -281,8 +281,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
card: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, marginBottom: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16, flexShrink: 1 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
link: { ...typography.body, textDecorationLine: "underline" },
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
flex: { flex: 1 },
|
||||
|
||||
@@ -84,7 +84,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 },
|
||||
hint: { ...typography.caption, marginBottom: 16 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { uploadImageToSupabase } from "../../lib/upload";
|
||||
import { resizeForUpload } from "../../lib/resize-image";
|
||||
import { uploadPickedImage } from "../../lib/upload";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { Button } from "../Button";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
@@ -46,13 +45,7 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resized = await resizeForUpload(picked.uri, picked.width, picked.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(picked, (file) => getPresignedUrlMutation.mutateAsync({ petId, ...file }));
|
||||
await confirmUploadMutation.mutateAsync({ petId, key });
|
||||
} catch {
|
||||
setError(t("onboarding.uploadError"));
|
||||
|
||||
@@ -101,7 +101,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
marginBottom: 16,
|
||||
},
|
||||
summary: { ...typography.body },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 },
|
||||
link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 8 },
|
||||
cta: { marginTop: 12 },
|
||||
});
|
||||
}
|
||||
@@ -144,7 +144,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
container: { padding: 24, gap: 6 },
|
||||
title: { ...typography.title, fontSize: 18, marginBottom: 4 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 10 },
|
||||
label: { ...typography.caption, marginTop: 10 },
|
||||
multiline: { height: 80, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
||||
cta: { marginTop: 16 },
|
||||
|
||||
@@ -129,7 +129,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
|
||||
link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -108,8 +108,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -137,6 +137,6 @@ function makeStyles(colors: ColorTokens) {
|
||||
optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray },
|
||||
optionText: { ...typography.body },
|
||||
optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
hint: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Sun, Moon, SunMoon } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former
|
||||
@@ -49,7 +48,7 @@ export function AppearanceSection() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
option: {
|
||||
flex: 1,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Gift } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
|
||||
export function InviteFriendsSection() {
|
||||
@@ -29,7 +28,7 @@ export function InviteFriendsSection() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 },
|
||||
label: { ...typography.body, flex: 1 },
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { LanguageSwitcher } from "../LanguageSwitcher";
|
||||
import type { ColorTokens } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Section chrome (title + spacing) around the shared LanguageSwitcher — see
|
||||
@@ -26,6 +25,6 @@ export function LanguageSection() {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...getTypography(colors).caption, marginBottom: 10 },
|
||||
});
|
||||
}
|
||||
@@ -69,6 +69,6 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
section: { gap: 12, alignItems: "center" },
|
||||
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
||||
versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
versionText: { ...typography.caption, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* Opt-out for the PET_SUGGESTION cron notification
|
||||
@@ -53,7 +52,7 @@ export function PetSuggestionsSection() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
toggleText: { flex: 1 },
|
||||
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Shared "section label" chrome (the small uppercase-ish title above a
|
||||
* Settings-screen section, e.g. "DARSTELLUNG"/"SPRACHE"). Extracted so
|
||||
* AppearanceSection/LanguageSection/PetSuggestionsSection can't drift out of
|
||||
* sync on this — they all had the exact same style object copy-pasted
|
||||
* before this file existed (found during code review of the Settings-screen
|
||||
* extraction, see mobile/found.md).
|
||||
*/
|
||||
export function sectionTitleStyle(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return { ...typography.label, textTransform: "none" as const, letterSpacing: 0, marginBottom: 10 };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { usePawToggle } from "../../hooks/usePawToggle";
|
||||
import { colors } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
@@ -14,53 +14,29 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand
|
||||
* und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt.
|
||||
* Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet.
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch über
|
||||
* usePawToggle (gemeinsam mit PawButton). Der Viewer setzt `key={story.id}`,
|
||||
* damit der Zustand pro Story neu startet.
|
||||
*/
|
||||
export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(initialReacted);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggle = useMutation(
|
||||
trpc.stories.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const previous = { reacted, count };
|
||||
setReacted(!reacted);
|
||||
setCount(reacted ? Math.max(0, count - 1) : count + 1);
|
||||
return previous;
|
||||
},
|
||||
// Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung.
|
||||
onSuccess: (data, _vars, previous) => {
|
||||
setReacted(data.reacted);
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setCount(Math.max(0, previous.count + delta));
|
||||
},
|
||||
onError: (_err, _vars, previous) => {
|
||||
if (previous) {
|
||||
setReacted(previous.reacted);
|
||||
setCount(previous.count);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
const paw = usePawToggle(initialReacted, initialCount);
|
||||
const toggle = useMutation(trpc.stories.toggleReaction.mutationOptions(paw.callbacks));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: reacted }}
|
||||
accessibilityLabel={paw.reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: paw.reacted }}
|
||||
hitSlop={8}
|
||||
style={styles.wrap}
|
||||
>
|
||||
<View style={styles.circle}>
|
||||
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
|
||||
<PawPrint size={20} color={paw.reacted ? colors.marigoldOrange : colors.pureWhite} fill={paw.reacted ? colors.marigoldOrange : "transparent"} />
|
||||
</View>
|
||||
{count > 0 && <Text style={styles.count}>{count}</Text>}
|
||||
{paw.count > 0 && <Text style={styles.count}>{paw.count}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type PawState = { reacted: boolean; count: number };
|
||||
|
||||
/**
|
||||
* Optimistic paw toggle shared by PawButton (posts) and StoryPawButton:
|
||||
* state and count flip immediately, the server's `reacted` decides the final
|
||||
* count (state before the tap + actual change), errors roll back. Pass
|
||||
* `callbacks` into the tRPC `mutationOptions`.
|
||||
*/
|
||||
export function usePawToggle(initialReacted: boolean, initialCount: number) {
|
||||
const [state, setState] = useState<PawState>({ reacted: initialReacted, count: initialCount });
|
||||
|
||||
const callbacks = {
|
||||
onMutate: (): PawState => {
|
||||
setState({ reacted: !state.reacted, count: state.reacted ? Math.max(0, state.count - 1) : state.count + 1 });
|
||||
return state;
|
||||
},
|
||||
onSuccess: (data: { reacted: boolean }, _vars: unknown, previous: PawState | undefined) => {
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setState({ reacted: data.reacted, count: Math.max(0, previous.count + delta) });
|
||||
},
|
||||
onError: (_err: unknown, _vars: unknown, previous: PawState | undefined) => {
|
||||
if (previous) setState(previous);
|
||||
},
|
||||
};
|
||||
|
||||
return { ...state, callbacks };
|
||||
}
|
||||
+23
-27
@@ -1,32 +1,13 @@
|
||||
import { File } from "expo-file-system";
|
||||
import { resizeForUpload, type ResizedImage } from "./resize-image";
|
||||
|
||||
/**
|
||||
* PUTs a local picked-image file directly to the Supabase Storage presigned
|
||||
* URL returned by `posts.getPresignedUrl` — mirrors web's XHR PUT in
|
||||
* `src/lib/upload-xhr.ts`, using expo-file-system's `File.upload` instead
|
||||
* since RN has no XHR-with-progress primitive by default. Content-Type must
|
||||
* exactly match what was passed to `getPresignedUrl` or Supabase responds
|
||||
* with 400 SignatureDoesNotMatch.
|
||||
*/
|
||||
export async function uploadImageToSupabase(uri: string, presignedUrl: string, mimeType: string): Promise<void> {
|
||||
const file = new File(uri);
|
||||
const result = await file.upload(presignedUrl, {
|
||||
httpMethod: "PUT",
|
||||
mimeType,
|
||||
headers: { "Content-Type": mimeType },
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs a local video file directly to a Mux direct-upload URL (from
|
||||
* `stories.createVideoUpload`). Separate function rather than reusing
|
||||
* `uploadImageToSupabase` above — same `File.upload` primitive, but a
|
||||
* different destination (Mux, not Supabase) and no signature-matching
|
||||
* requirement on Content-Type, so keeping the working photo path untouched.
|
||||
* PUTs a local file directly to a presigned upload URL — Supabase Storage
|
||||
* (photos, mirrors web's XHR PUT in `src/lib/upload-xhr.ts`) or Mux (story
|
||||
* videos). Uses expo-file-system's `File.upload` since RN has no
|
||||
* XHR-with-progress primitive by default. For Supabase, Content-Type must
|
||||
* exactly match what was passed to `getPresignedUrl` or it responds with
|
||||
* 400 SignatureDoesNotMatch.
|
||||
*/
|
||||
export async function uploadFileToUrl(
|
||||
uri: string,
|
||||
@@ -47,6 +28,21 @@ export async function uploadFileToUrl(
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`);
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picked photo → downscaled JPEG → presigned URL → Supabase PUT. `getUploadUrl`
|
||||
* is the caller's `*.getPresignedUrl` mutation (posts, stories or media), with
|
||||
* the petId already bound. Returns the storage key and the uploaded file's uri.
|
||||
*/
|
||||
export async function uploadPickedImage(
|
||||
asset: { uri: string; width: number; height: number },
|
||||
getUploadUrl: (file: { contentType: ResizedImage["mimeType"]; fileSize: number }) => Promise<{ presignedUrl: string; key: string }>
|
||||
): Promise<{ key: string; uri: string }> {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getUploadUrl({ contentType: resized.mimeType, fileSize: resized.fileSize });
|
||||
await uploadFileToUrl(resized.uri, presignedUrl, resized.mimeType);
|
||||
return { key, uri: resized.uri };
|
||||
}
|
||||
@@ -138,6 +138,13 @@ export function getTypography(theme: ColorTokens) {
|
||||
textTransform: "uppercase" as const,
|
||||
color: theme.quietGray,
|
||||
},
|
||||
/** Like `label`, but as written (no uppercase, no extra tracking). */
|
||||
caption: {
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: theme.quietGray,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user