// Standalone card editor (no login/backend): take/pick a photo → card front; // fill name/title/QR link → Save. Saved cards live in the on-device gallery // (localCardsStore). Opening with ?cardId=… edits an existing card. import { MaterialCommunityIcons } from "@expo/vector-icons"; import { ResizeMode, Video } from "expo-av"; import * as ImagePicker from "expo-image-picker"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Image, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native"; import { CardBackTemplate } from "../src/components/card/CardBackTemplate"; import { CardViewer } from "../src/components/card/CardViewer"; import { defaultLinks, LinksEditor } from "../src/components/LinksEditor"; import { newLayerId } from "../src/stores/cardStore"; import { useDraftStore } from "../src/stores/draftStore"; import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore"; import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card"; import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore"; function textLayer( text: string, y: number, size: number, color: string, align: "left" | "right" = "left", ): TextLayer { return { id: newLayerId(), type: "text", x: 0.08, y, width: 0.84, height: 0.12, opacity: 1, zIndex: 10, text, fontFamily: "System", fontWeight: 700, fontSize: size, lineHeight: size + 4, letterSpacing: 0, color, align, }; } function buildDemoCard( mediaUri: string, isVideo: boolean, name: string, title: string, ): CardDefinition { return { id: "demo", ownerId: "demo", handle: "demo", version: 1, face: { // Name + title stacked at the top-right. layers: [ textLayer(name, 0.06, 28, "#ffffff", "right"), title ? textLayer(title, 0.135, 17, "#f0f0f2", "right") : textLayer("", 0.135, 1, "#fff"), ], background: { type: isVideo ? "video" : "image", value: mediaUri }, entryAnimation: "fade", }, back: { layers: [ { id: newLayerId(), type: "qr", x: 0.27, y: 0.14, width: 0.46, height: 0.31, opacity: 1, zIndex: 5, }, textLayer(name, 0.58, 26, "#f5f5f7"), title ? textLayer(title, 0.67, 17, "#9a9aa0") : textLayer("", 0.67, 1, "#fff"), ], background: { type: "solid", value: "#101014" }, entryAnimation: "fade", }, palette: { colors: [] }, settings: DEFAULT_SETTINGS, }; } export default function CardEditorScreen() { const router = useRouter(); const { cardId } = useLocalSearchParams<{ cardId?: string }>(); const upsert = useLocalCardsStore((s) => s.upsert); const remove = useLocalCardsStore((s) => s.remove); const getById = useLocalCardsStore((s) => s.getById); // A stable id for the lifetime of this editor (new card or the one we're editing). const [id] = useState(() => cardId ?? newLayerId()); const [imageUri, setImageUri] = useState(null); const [videoUri, setVideoUri] = useState(null); const [name, setName] = useState("Omar Sobh"); const [title, setTitle] = useState("Founder & CEO"); const [url, setUrl] = useState("https://cardclaws.com/omar"); const [links, setLinks] = useState(() => defaultLinks()); const [showing, setShowing] = useState(false); const [onBack, setOnBack] = useState(false); const [saving, setSaving] = useState(false); // Load an existing card and jump straight to viewing it. useEffect(() => { if (!cardId) return; const existing = getById(cardId); if (existing) { setImageUri(existing.imagePath || null); setVideoUri(existing.videoPath ?? null); setName(existing.name); setTitle(existing.title); setUrl(existing.url); setLinks(existing.links ?? []); setOnBack(false); setShowing(true); } }, [cardId, getById]); // When returning from the AI generators, adopt the image/video they produced. useFocusEffect( useCallback(() => { const draft = useDraftStore.getState(); if (draft.pendingImageUri) { setImageUri(draft.pendingImageUri); setVideoUri(null); setShowing(false); draft.setPendingImageUri(null); } if (draft.pendingVideoUri) { setVideoUri(draft.pendingVideoUri); setShowing(false); draft.setPendingVideoUri(null); } }, []), ); const pickFromLibrary = async () => { const res = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [2, 3], quality: 0.9, }); // A photo replaces any AI video on the front. if (!res.canceled) { setImageUri(res.assets[0].uri); setVideoUri(null); } }; const takePhoto = async () => { const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) return; const res = await ImagePicker.launchCameraAsync({ allowsEditing: true, aspect: [2, 3], quality: 0.9, }); if (!res.canceled) { setImageUri(res.assets[0].uri); setVideoUri(null); } }; const save = async () => { if (!imageUri && !videoUri) return; setSaving(true); try { const imagePath = imageUri ? await persistImage(imageUri, id) : (getById(id)?.imagePath ?? ""); const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined; upsert({ id, name, title, url, imagePath, videoPath, links, updatedAt: Date.now() }); if (imageUri) setImageUri(imagePath); if (videoPath) setVideoUri(videoPath); setOnBack(false); setShowing(true); } finally { setSaving(false); } }; const onDelete = async () => { const existing = getById(id); if (existing?.imagePath) await deleteImage(existing.imagePath); if (existing?.videoPath) await deleteImage(existing.videoPath); remove(id); router.replace("/"); }; const media = videoUri ?? imageUri; if (showing && media) { const card = buildDemoCard(media, !!videoUri, name, title); return ( ); } return ( {cardId ? "Edit your card" : "Make your card"} {videoUri ? ( ); } function Field({ label, ...props }: { label: string } & React.ComponentProps) { return ( {label} ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: "#0a0a0c" }, content: { padding: 20, gap: 14 }, h1: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" }, preview: { width: "100%", aspectRatio: 2 / 3, borderRadius: 20, backgroundColor: "#15151a", maxHeight: 360, alignSelf: "center", }, previewEmpty: { alignItems: "center", justifyContent: "center" }, previewHint: { color: "#6b6b70" }, photoRow: { flexDirection: "row", gap: 12 }, photoBtn: { flex: 1, backgroundColor: "#222228", borderRadius: 14, paddingVertical: 14, alignItems: "center", }, photoText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 }, aiBtn: { flex: 1, flexDirection: "row", gap: 8, backgroundColor: "#222228", borderRadius: 14, paddingVertical: 14, alignItems: "center", justifyContent: "center", }, field: { gap: 6 }, label: { color: "#9a9aa0", fontSize: 14 }, sectionLabel: { color: "#f5f5f7", fontSize: 16, fontWeight: "700", marginTop: 12 }, input: { backgroundColor: "#15151a", color: "#f5f5f7", borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16, }, cta: { backgroundColor: "#ff3b30", borderRadius: 16, paddingVertical: 16, alignItems: "center", marginTop: 8, }, ctaDisabled: { opacity: 0.4 }, ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 }, deleteText: { color: "#ff453a", textAlign: "center", paddingVertical: 14, fontWeight: "600" }, viewerRoot: { flex: 1, backgroundColor: "#0a0a0c" }, swipeHint: { position: "absolute", bottom: 96, alignSelf: "center", color: "#9a9aa0", fontSize: 14, }, viewerActions: { position: "absolute", bottom: 36, alignSelf: "center", flexDirection: "row", gap: 12, }, pillDark: { backgroundColor: "#222228", borderRadius: 20, paddingHorizontal: 24, paddingVertical: 12, }, pillText: { color: "#f5f5f7", fontWeight: "600" }, });