Add core functionality for mobile post scheduling app

Adds necessary dependencies including axios and react-native-calendars to pnpm-lock.yaml.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 7b0991ce-c7b8-4c82-9acc-fd3f9e762a01
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: dc1266fa-8375-43e1-aca0-9df31350f647
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/86064bd6-c937-4ca5-a5bf-bbef5749fb60/7b0991ce-c7b8-4c82-9acc-fd3f9e762a01/kWnlAIM
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
antoinepiron
2026-05-03 11:41:45 +00:00
parent 5b0eedb94b
commit bbbcf9f586
31 changed files with 10631 additions and 9 deletions
@@ -0,0 +1,84 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useColors } from "@/hooks/useColors";
import { PostizIntegration } from "@/context/PostizContext";
interface ChannelChipProps {
integration: PostizIntegration;
selected: boolean;
onToggle: () => void;
}
export function ChannelChip({ integration, selected, onToggle }: ChannelChipProps) {
const colors = useColors();
return (
<TouchableOpacity
onPress={onToggle}
activeOpacity={0.7}
style={[
styles.chip,
{
backgroundColor: selected ? colors.primary + "20" : colors.card,
borderColor: selected ? colors.primary : colors.border,
},
]}
>
{integration.picture ? (
<Image
source={{ uri: integration.picture }}
style={styles.avatar}
contentFit="cover"
/>
) : (
<View style={[styles.avatarFallback, { backgroundColor: colors.secondary }]}>
<Feather name="globe" size={12} color={colors.mutedForeground} />
</View>
)}
<Text
style={[
styles.name,
{ color: selected ? colors.primary : colors.foreground },
]}
numberOfLines={1}
>
{integration.name}
</Text>
{selected && (
<Feather name="check" size={12} color={colors.primary} />
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
chip: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
gap: 6,
maxWidth: 150,
},
avatar: {
width: 20,
height: 20,
borderRadius: 10,
},
avatarFallback: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
name: {
fontSize: 12,
fontFamily: "Inter_500Medium",
flexShrink: 1,
},
});
@@ -0,0 +1,54 @@
import React, { Component, ComponentType, PropsWithChildren } from "react";
import { ErrorFallback, ErrorFallbackProps } from "@/components/ErrorFallback";
export type ErrorBoundaryProps = PropsWithChildren<{
FallbackComponent?: ComponentType<ErrorFallbackProps>;
onError?: (error: Error, stackTrace: string) => void;
}>;
type ErrorBoundaryState = { error: Error | null };
/**
* This is a special case for for using the class components. Error boundaries must be class components because React only provides error boundary functionality through lifecycle methods (componentDidCatch and getDerivedStateFromError) which are not available in functional components.
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static defaultProps: {
FallbackComponent: ComponentType<ErrorFallbackProps>;
} = {
FallbackComponent: ErrorFallback,
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
if (typeof this.props.onError === "function") {
this.props.onError(error, info.componentStack);
}
}
resetError = (): void => {
this.setState({ error: null });
};
render() {
const { FallbackComponent } = this.props;
return this.state.error && FallbackComponent ? (
<FallbackComponent
error={this.state.error}
resetError={this.resetError}
/>
) : (
this.props.children
);
}
}
@@ -0,0 +1,278 @@
import { Feather } from "@expo/vector-icons";
import { reloadAppAsync } from "expo";
import React, { useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
export type ErrorFallbackProps = {
error: Error;
resetError: () => void;
};
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [isModalVisible, setIsModalVisible] = useState(false);
const handleRestart = async () => {
try {
await reloadAppAsync();
} catch (restartError) {
console.error("Failed to restart app:", restartError);
resetError();
}
};
const formatErrorDetails = (): string => {
let details = `Error: ${error.message}\n\n`;
if (error.stack) {
details += `Stack Trace:\n${error.stack}`;
}
return details;
};
const monoFont = Platform.select({
ios: "Menlo",
android: "monospace",
default: "monospace",
});
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
{__DEV__ ? (
<Pressable
onPress={() => setIsModalVisible(true)}
accessibilityLabel="View error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.topButton,
{
top: insets.top + 16,
backgroundColor: colors.card,
opacity: pressed ? 0.8 : 1,
},
]}
>
<Feather name="alert-circle" size={20} color={colors.foreground} />
</Pressable>
) : null}
<View style={styles.content}>
<Text style={[styles.title, { color: colors.foreground }]}>
Something went wrong
</Text>
<Text style={[styles.message, { color: colors.mutedForeground }]}>
Please reload the app to continue.
</Text>
<Pressable
onPress={handleRestart}
style={({ pressed }) => [
styles.button,
{
backgroundColor: colors.primary,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text
style={[
styles.buttonText,
{ color: colors.primaryForeground },
]}
>
Try Again
</Text>
</Pressable>
</View>
{__DEV__ ? (
<Modal
visible={isModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setIsModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: colors.background },
]}
>
<View
style={[
styles.modalHeader,
{ borderBottomColor: colors.border },
]}
>
<Text style={[styles.modalTitle, { color: colors.foreground }]}>
Error Details
</Text>
<Pressable
onPress={() => setIsModalVisible(false)}
accessibilityLabel="Close error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.closeButton,
{ opacity: pressed ? 0.6 : 1 },
]}
>
<Feather name="x" size={24} color={colors.foreground} />
</Pressable>
</View>
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={[
styles.modalScrollContent,
{ paddingBottom: insets.bottom + 16 },
]}
showsVerticalScrollIndicator
>
<View
style={[
styles.errorContainer,
{ backgroundColor: colors.card },
]}
>
<Text
style={[
styles.errorText,
{
color: colors.foreground,
fontFamily: monoFont,
},
]}
selectable
>
{formatErrorDetails()}
</Text>
</View>
</ScrollView>
</View>
</View>
</Modal>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: 24,
},
content: {
alignItems: "center",
justifyContent: "center",
gap: 16,
width: "100%",
maxWidth: 600,
},
title: {
fontSize: 28,
fontWeight: "700",
textAlign: "center",
lineHeight: 40,
},
message: {
fontSize: 16,
textAlign: "center",
lineHeight: 24,
},
topButton: {
position: "absolute",
right: 16,
width: 44,
height: 44,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
},
button: {
paddingVertical: 16,
borderRadius: 8,
paddingHorizontal: 24,
minWidth: 200,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
buttonText: {
fontWeight: "600",
textAlign: "center",
fontSize: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
width: "100%",
height: "90%",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 12,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
},
closeButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
},
modalScrollView: {
flex: 1,
},
modalScrollContent: {
padding: 16,
},
errorContainer: {
width: "100%",
borderRadius: 8,
overflow: "hidden",
padding: 16,
},
errorText: {
fontSize: 12,
lineHeight: 18,
width: "100%",
},
});
@@ -0,0 +1,29 @@
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
} from "react-native-keyboard-controller";
import { Platform, ScrollView, ScrollViewProps } from "react-native";
type Props = KeyboardAwareScrollViewProps & ScrollViewProps;
export function KeyboardAwareScrollViewCompat({
children,
keyboardShouldPersistTaps = "handled",
...props
}: Props) {
if (Platform.OS === "web") {
return (
<ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} {...props}>
{children}
</ScrollView>
);
}
return (
<KeyboardAwareScrollView
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
{...props}
>
{children}
</KeyboardAwareScrollView>
);
}
@@ -0,0 +1,195 @@
import { Feather } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import React, { useRef } from "react";
import {
Alert,
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useColors } from "@/hooks/useColors";
import { PostizPost } from "@/context/PostizContext";
import { StatusBadge } from "./StatusBadge";
interface PostCardProps {
post: PostizPost;
onDelete: (id: string) => Promise<void>;
}
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}
function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["name"] {
const t = (type ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x")) return "twitter";
if (t.includes("linkedin")) return "linkedin";
if (t.includes("instagram")) return "instagram";
if (t.includes("facebook")) return "facebook";
if (t.includes("youtube")) return "youtube";
return "globe";
}
export function PostCard({ post, onDelete }: PostCardProps) {
const colors = useColors();
const swipeRef = useRef<Swipeable>(null);
const handleDelete = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
Alert.alert("Delete Post", "Are you sure you want to delete this post?", [
{
text: "Cancel",
style: "cancel",
onPress: () => swipeRef.current?.close(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
await onDelete(post.id);
},
},
]);
};
const renderRightActions = (
_progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const scale = dragX.interpolate({
inputRange: [-80, 0],
outputRange: [1, 0.8],
extrapolate: "clamp",
});
return (
<TouchableOpacity
style={[styles.deleteAction, { backgroundColor: colors.destructive }]}
onPress={handleDelete}
activeOpacity={0.8}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Feather name="trash-2" size={20} color="#fff" />
</Animated.View>
</TouchableOpacity>
);
};
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const truncatedContent =
post.content.length > 140
? post.content.slice(0, 140) + "…"
: post.content;
return (
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
rightThreshold={40}
friction={2}
>
<View
style={[
styles.card,
{ backgroundColor: colors.card, borderBottomColor: colors.border },
]}
>
<View style={styles.header}>
<View style={styles.integrations}>
{integrations.slice(0, 3).map((intg) => (
<View
key={intg.id}
style={[
styles.networkIcon,
{ backgroundColor: colors.secondary },
]}
>
<Feather
name={getNetworkIcon(intg.type ?? intg.internalType)}
size={12}
color={colors.mutedForeground}
/>
</View>
))}
{integrations.length > 3 && (
<Text style={[styles.moreText, { color: colors.mutedForeground }]}>
+{integrations.length - 3}
</Text>
)}
</View>
<StatusBadge status={post.status} />
</View>
<Text style={[styles.content, { color: colors.foreground }]}>
{truncatedContent}
</Text>
<View style={styles.footer}>
<Feather name="clock" size={12} color={colors.mutedForeground} />
<Text style={[styles.date, { color: colors.mutedForeground }]}>
{formatDate(post.publishDate)}
</Text>
</View>
</View>
</Swipeable>
);
}
const styles = StyleSheet.create({
card: {
paddingHorizontal: 20,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
integrations: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
networkIcon: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
},
moreText: {
fontSize: 11,
fontFamily: "Inter_500Medium",
},
content: {
fontSize: 14,
fontFamily: "Inter_400Regular",
lineHeight: 20,
},
footer: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
date: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
deleteAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
});
@@ -0,0 +1,50 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { useColors } from "@/hooks/useColors";
type PostStatus = "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
interface StatusBadgeProps {
status: PostStatus;
}
export function StatusBadge({ status }: StatusBadgeProps) {
const colors = useColors();
const config: Record<PostStatus, { bg: string; text: string; label: string }> = {
QUEUE: { bg: colors.warning + "25", text: colors.warning, label: "Queue" },
PUBLISHED: { bg: colors.success + "25", text: colors.success, label: "Published" },
ERROR: { bg: colors.error + "25", text: colors.error, label: "Error" },
DRAFT: { bg: colors.muted, text: colors.mutedForeground, label: "Draft" },
};
const { bg, text, label } = config[status] ?? config.DRAFT;
return (
<View style={[styles.badge, { backgroundColor: bg }]}>
<View style={[styles.dot, { backgroundColor: text }]} />
<Text style={[styles.label, { color: text }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 20,
gap: 4,
},
dot: {
width: 5,
height: 5,
borderRadius: 3,
},
label: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.3,
},
});