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,357 @@
import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import React, { useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Calendar, DateData } from "react-native-calendars";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
import { StatusBadge } from "@/components/StatusBadge";
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function toDateKey(dateStr: string): string {
try {
return formatDate(new Date(dateStr));
} catch {
return dateStr.slice(0, 10);
}
}
function formatPostTime(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
export default function CalendarScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { client, isConfigured } = usePostiz();
const now = new Date();
const [currentMonth, setCurrentMonth] = useState({
year: now.getFullYear(),
month: now.getMonth() + 1,
});
const [selectedDay, setSelectedDay] = useState<string | null>(
formatDate(now)
);
const startDate = useMemo(() => {
const d = new Date(currentMonth.year, currentMonth.month - 1, 1);
return d.toISOString();
}, [currentMonth]);
const endDate = useMemo(() => {
const d = new Date(currentMonth.year, currentMonth.month, 0, 23, 59, 59);
return d.toISOString();
}, [currentMonth]);
const { data: posts, isLoading, error, refetch } = useQuery<PostizPost[]>({
queryKey: ["posts", startDate, endDate],
queryFn: async () => {
if (!client) return [];
const res = await client.get("/posts", {
params: { startDate, endDate },
});
return Array.isArray(res.data) ? res.data : res.data?.posts ?? [];
},
enabled: !!client,
retry: 1,
});
const markedDates = useMemo(() => {
const marks: Record<string, {
dots?: Array<{ color: string }>;
selected?: boolean;
selectedColor?: string;
}> = {};
(posts ?? []).forEach((post) => {
const key = toDateKey(post.publishDate);
if (!marks[key]) marks[key] = { dots: [] };
const dotColor =
post.status === "PUBLISHED"
? colors.success
: post.status === "ERROR"
? colors.error
: colors.primary;
marks[key].dots = [...(marks[key].dots ?? []), { color: dotColor }];
});
if (selectedDay) {
marks[selectedDay] = {
...(marks[selectedDay] ?? {}),
selected: true,
selectedColor: colors.primary,
};
}
return marks;
}, [posts, selectedDay, colors]);
const dayPosts = useMemo(() => {
if (!selectedDay || !posts) return [];
return posts.filter((p) => toDateKey(p.publishDate) === selectedDay);
}, [posts, selectedDay]);
if (!isConfigured) {
return (
<View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="settings" size={32} color={colors.mutedForeground} />
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
Not Configured
</Text>
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
Add your API key in Settings to get started
</Text>
<TouchableOpacity
style={[styles.btn, { backgroundColor: colors.primary }]}
onPress={() => router.push("/(tabs)/settings")}
activeOpacity={0.8}
>
<Text style={[styles.btnText, { color: colors.primaryForeground }]}>
Open Settings
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View
style={[
styles.container,
{
backgroundColor: colors.background,
paddingTop: Platform.OS === "web" ? 67 : 0,
paddingBottom: Platform.OS === "web" ? 34 : 0,
},
]}
>
<Calendar
key={colors.background}
theme={{
backgroundColor: colors.background,
calendarBackground: colors.background,
textSectionTitleColor: colors.mutedForeground,
selectedDayBackgroundColor: colors.primary,
selectedDayTextColor: colors.primaryForeground,
todayTextColor: colors.primary,
dayTextColor: colors.foreground,
textDisabledColor: colors.mutedForeground + "60",
dotColor: colors.primary,
selectedDotColor: colors.primaryForeground,
arrowColor: colors.primary,
monthTextColor: colors.foreground,
indicatorColor: colors.primary,
textDayFontFamily: "Inter_400Regular",
textMonthFontFamily: "Inter_600SemiBold",
textDayHeaderFontFamily: "Inter_500Medium",
textDayFontSize: 14,
textMonthFontSize: 16,
textDayHeaderFontSize: 12,
}}
markingType="multi-dot"
markedDates={markedDates}
onDayPress={(day: DateData) => setSelectedDay(day.dateString)}
onMonthChange={(month: DateData) => {
setCurrentMonth({ year: month.year, month: month.month });
}}
/>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
{isLoading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} />
</View>
) : error ? (
<View style={styles.centered}>
<Feather name="alert-circle" size={24} color={colors.error} />
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
Failed to load posts
</Text>
<TouchableOpacity onPress={() => refetch()} style={styles.retryBtn}>
<Text style={[styles.retryText, { color: colors.primary }]}>Retry</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={dayPosts}
keyExtractor={(item) => item.id}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
selectedDay ? (
<View style={styles.dayHeader}>
<Text style={[styles.dayHeaderText, { color: colors.mutedForeground }]}>
{new Date(selectedDay).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
</Text>
<Text style={[styles.countText, { color: colors.mutedForeground }]}>
{dayPosts.length} post{dayPosts.length !== 1 ? "s" : ""}
</Text>
</View>
) : null
}
ListEmptyComponent={
<View style={styles.emptyDay}>
<Text style={[styles.emptyDayText, { color: colors.mutedForeground }]}>
No posts scheduled
</Text>
<TouchableOpacity
onPress={() => router.push("/(tabs)/compose")}
style={styles.composeHint}
>
<Feather name="plus-circle" size={14} color={colors.primary} />
<Text style={[styles.composeHintText, { color: colors.primary }]}>
Create a post
</Text>
</TouchableOpacity>
</View>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.dayPost,
{ borderBottomColor: colors.border },
]}
activeOpacity={0.7}
>
<View style={styles.dayPostLeft}>
<Text style={[styles.timeText, { color: colors.primary }]}>
{formatPostTime(item.publishDate)}
</Text>
<Text
style={[styles.postContent, { color: colors.foreground }]}
numberOfLines={2}
>
{item.content}
</Text>
</View>
<StatusBadge status={item.status} />
</TouchableOpacity>
)}
scrollEnabled={dayPosts.length > 0}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 10,
paddingHorizontal: 32,
},
emptyTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
emptyText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
btn: {
marginTop: 8,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
btnText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
},
divider: {
height: StyleSheet.hairlineWidth,
},
dayHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 12,
},
dayHeaderText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
countText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
dayPost: {
flexDirection: "row",
alignItems: "flex-start",
paddingHorizontal: 20,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 12,
},
dayPostLeft: {
flex: 1,
gap: 4,
},
timeText: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
postContent: {
fontSize: 13,
fontFamily: "Inter_400Regular",
lineHeight: 18,
},
emptyDay: {
alignItems: "center",
paddingTop: 32,
gap: 10,
},
emptyDayText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
},
composeHint: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
composeHintText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
retryBtn: {
marginTop: 4,
},
retryText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
});