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,142 @@
import { BlurView } from "expo-blur";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Tabs } from "expo-router";
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
import { SymbolView } from "expo-symbols";
import { Feather } from "@expo/vector-icons";
import React from "react";
import { Platform, StyleSheet, View, useColorScheme } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
function NativeTabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf={{ default: "calendar", selected: "calendar.fill" }} />
<Label>Calendar</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="posts">
<Icon sf={{ default: "list.bullet", selected: "list.bullet" }} />
<Label>Posts</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="compose">
<Icon sf={{ default: "plus.circle", selected: "plus.circle.fill" }} />
<Label>Compose</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf={{ default: "gear", selected: "gear" }} />
<Label>Settings</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
function ClassicTabLayout() {
const colors = useColors();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const isIOS = Platform.OS === "ios";
const isWeb = Platform.OS === "web";
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.mutedForeground,
headerStyle: { backgroundColor: colors.background },
headerTitleStyle: {
fontFamily: "Inter_600SemiBold",
color: colors.foreground,
fontSize: 17,
},
headerShadowVisible: false,
headerTintColor: colors.primary,
tabBarStyle: {
position: "absolute",
backgroundColor: isIOS ? "transparent" : colors.background,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
elevation: 0,
...(isWeb ? { height: 84 } : {}),
},
tabBarBackground: () =>
isIOS ? (
<BlurView
intensity={80}
tint={isDark ? "dark" : "light"}
style={StyleSheet.absoluteFill}
/>
) : isWeb ? (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: colors.background },
]}
/>
) : null,
tabBarLabelStyle: {
fontFamily: "Inter_500Medium",
fontSize: 11,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Calendar",
tabBarIcon: ({ color }) =>
isIOS ? (
<SymbolView name="calendar" tintColor={color} size={22} />
) : (
<Feather name="calendar" size={21} color={color} />
),
}}
/>
<Tabs.Screen
name="posts"
options={{
title: "Posts",
tabBarIcon: ({ color }) =>
isIOS ? (
<SymbolView name="list.bullet" tintColor={color} size={22} />
) : (
<Feather name="list" size={21} color={color} />
),
}}
/>
<Tabs.Screen
name="compose"
options={{
title: "Compose",
tabBarIcon: ({ color }) =>
isIOS ? (
<SymbolView name="plus.circle" tintColor={color} size={24} />
) : (
<Feather name="plus-circle" size={22} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) =>
isIOS ? (
<SymbolView name="gear" tintColor={color} size={22} />
) : (
<Feather name="settings" size={21} color={color} />
),
}}
/>
</Tabs>
);
}
export default function TabLayout() {
if (isLiquidGlassAvailable()) {
return <NativeTabLayout />;
}
return <ClassicTabLayout />;
}
@@ -0,0 +1,533 @@
import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import * as ImagePicker from "expo-image-picker";
import { File } from "expo-file-system";
import { fetch as expoFetch } from "expo/fetch";
import React, { useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
export default function ComposeScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz();
const queryClient = useQueryClient();
const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [postNow, setPostNow] = useState(false);
const [scheduleDate, setScheduleDate] = useState(
() => new Date(Date.now() + 60 * 60 * 1000)
);
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { data: integrations, isLoading: loadingIntegrations } =
useQuery<PostizIntegration[]>({
queryKey: ["integrations"],
queryFn: async () => {
if (!client) return [];
const res = await client.get("/integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? [];
},
enabled: !!client,
staleTime: 60000,
});
const toggleChannel = (id: string) => {
setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
);
};
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert("Permission required", "Allow access to your photo library.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: false,
quality: 0.85,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
}
};
const removeImage = () => setImageUri(null);
const uploadImage = async (): Promise<PostizUploadResult | null> => {
if (!imageUri) return null;
setUploading(true);
try {
const formData = new FormData();
if (Platform.OS === "web") {
const response = await expoFetch(imageUri);
const blob = await response.blob();
formData.append("file", blob, "upload.jpg");
} else {
const file = new File(imageUri);
formData.append("file", file as unknown as Blob);
}
const uploadRes = await expoFetch(`${baseUrl}/upload`, {
method: "POST",
headers: { Authorization: apiKey },
body: formData,
});
const data = await uploadRes.json() as PostizUploadResult;
return data;
} catch (e) {
Alert.alert("Upload Failed", "Could not upload image. Please try again.");
return null;
} finally {
setUploading(false);
}
};
const handleSubmit = async () => {
if (!client) return;
if (!content.trim()) {
Alert.alert("Empty post", "Please write something before posting.");
return;
}
if (selectedChannels.length === 0) {
Alert.alert("No channel", "Please select at least one channel.");
return;
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true);
try {
let media: Array<{ id: string; path: string }> = [];
if (imageUri) {
const uploaded = await uploadImage();
if (uploaded) {
media = [{ id: uploaded.id, path: uploaded.path }];
}
}
const payload = {
type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
content: [{ content: content.trim(), image: media }],
integrations: selectedChannels,
};
await client.post("/posts", payload);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert(
"Posted!",
postNow ? "Your post has been published." : "Post scheduled successfully.",
[{ text: "OK", onPress: resetForm }]
);
queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Failed", "Could not submit post. Please try again.");
} finally {
setSubmitting(false);
}
};
const resetForm = () => {
setContent("");
setSelectedChannels([]);
setPostNow(false);
setImageUri(null);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
};
const formatDateLabel = (d: Date) =>
d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formatTimeLabel = (d: Date) =>
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
if (!isConfigured) {
return (
<View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="lock" size={32} color={colors.mutedForeground} />
<Text style={[styles.sectionTitle, { color: colors.foreground }]}>
Not Configured
</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add your API key in Settings
</Text>
</View>
);
}
return (
<KeyboardAwareScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={[
styles.container,
{
paddingTop: Platform.OS === "web" ? 67 : 16,
paddingBottom:
Platform.OS === "web" ? 100 : insets.bottom + 80,
},
]}
bottomOffset={80}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View
style={[
styles.textArea,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<TextInput
style={[styles.textInput, { color: colors.foreground }]}
placeholder="What do you want to post?"
placeholderTextColor={colors.mutedForeground}
multiline
value={content}
onChangeText={setContent}
maxLength={3000}
textAlignVertical="top"
/>
<Text style={[styles.charCount, { color: colors.mutedForeground }]}>
{content.length}/3000
</Text>
</View>
{imageUri && (
<View style={styles.imagePreviewWrap}>
<Image
source={{ uri: imageUri }}
style={[styles.imagePreview, { borderColor: colors.border }]}
contentFit="cover"
/>
<TouchableOpacity
onPress={removeImage}
style={[styles.removeImg, { backgroundColor: colors.destructive }]}
>
<Feather name="x" size={12} color="#fff" />
</TouchableOpacity>
</View>
)}
<TouchableOpacity
onPress={pickImage}
activeOpacity={0.7}
style={[
styles.mediaBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<Feather name="image" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
{imageUri ? "Change image" : "Add image"}
</Text>
</TouchableOpacity>
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>
CHANNELS
</Text>
{loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance.
</Text>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.channelList}
>
{(integrations ?? []).map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
))}
</ScrollView>
)}
<View
style={[
styles.scheduleRow,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<View style={styles.scheduleRowLeft}>
<Feather name="zap" size={16} color={colors.primary} />
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}>
Post now
</Text>
</View>
<Switch
value={postNow}
onValueChange={setPostNow}
trackColor={{ false: colors.muted, true: colors.primary }}
thumbColor={colors.primaryForeground}
/>
</View>
{!postNow && (
<View style={styles.dateTimeRow}>
<TouchableOpacity
onPress={() => {
setShowTimePicker(false);
setShowDatePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7}
>
<Feather name="calendar" size={14} color={colors.primary} />
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
{formatDateLabel(scheduleDate)}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setShowDatePicker(false);
setShowTimePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7}
>
<Feather name="clock" size={14} color={colors.primary} />
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
{formatTimeLabel(scheduleDate)}
</Text>
</TouchableOpacity>
</View>
)}
{showDatePicker && (
<DateTimePicker
value={scheduleDate}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
minimumDate={new Date()}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
if (Platform.OS !== "ios") setShowDatePicker(false);
if (date) {
const merged = new Date(scheduleDate);
merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
setScheduleDate(merged);
}
}}
/>
)}
{showTimePicker && (
<DateTimePicker
value={scheduleDate}
mode="time"
display={Platform.OS === "ios" ? "spinner" : "default"}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
if (Platform.OS !== "ios") setShowTimePicker(false);
if (date) {
const merged = new Date(scheduleDate);
merged.setHours(date.getHours(), date.getMinutes());
setScheduleDate(merged);
}
}}
/>
)}
<TouchableOpacity
onPress={handleSubmit}
activeOpacity={0.85}
disabled={submitting || uploading}
style={[
styles.submitBtn,
{
backgroundColor:
submitting || uploading ? colors.muted : colors.primary,
},
]}
>
{submitting || uploading ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather
name={postNow ? "send" : "clock"}
size={16}
color={colors.primaryForeground}
/>
<Text style={[styles.submitText, { color: colors.primaryForeground }]}>
{postNow ? "Publish Now" : "Schedule Post"}
</Text>
</>
)}
</TouchableOpacity>
</KeyboardAwareScrollView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
gap: 14,
},
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 10,
},
textArea: {
borderRadius: 14,
borderWidth: 1,
padding: 14,
minHeight: 140,
},
textInput: {
fontSize: 15,
fontFamily: "Inter_400Regular",
lineHeight: 22,
minHeight: 100,
},
charCount: {
fontSize: 11,
fontFamily: "Inter_400Regular",
alignSelf: "flex-end",
marginTop: 4,
},
imagePreviewWrap: {
position: "relative",
alignSelf: "flex-start",
},
imagePreview: {
width: 120,
height: 120,
borderRadius: 10,
borderWidth: 1,
},
removeImg: {
position: "absolute",
top: 4,
right: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
mediaBtn: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
},
mediaBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
sectionLabel: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.8,
marginBottom: -6,
},
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
},
scheduleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
scheduleRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
scheduleLabel: {
fontSize: 15,
fontFamily: "Inter_500Medium",
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
},
dateBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
submitBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
});
@@ -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",
},
});
@@ -0,0 +1,257 @@
import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import {
ActivityIndicator,
FlatList,
Platform,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
const FILTERS: { key: FilterType; label: string }[] = [
{ key: "all", label: "All" },
{ key: "QUEUE", label: "Queue" },
{ key: "PUBLISHED", label: "Published" },
{ key: "DRAFT", label: "Draft" },
{ key: "ERROR", label: "Error" },
];
export default function PostsScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { client, isConfigured } = usePostiz();
const queryClient = useQueryClient();
const [filter, setFilter] = useState<FilterType>("all");
const [refreshing, setRefreshing] = useState(false);
const start = new Date();
start.setMonth(start.getMonth() - 3);
const end = new Date();
end.setMonth(end.getMonth() + 6);
const { data: posts, isLoading, error, refetch } = useQuery<PostizPost[]>({
queryKey: ["posts-list"],
queryFn: async () => {
if (!client) return [];
const res = await client.get("/posts", {
params: {
startDate: start.toISOString(),
endDate: end.toISOString(),
},
});
return Array.isArray(res.data) ? res.data : res.data?.posts ?? [];
},
enabled: !!client,
retry: 1,
});
const filteredPosts =
filter === "all"
? posts ?? []
: (posts ?? []).filter((p) => p.status === filter);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleDelete = async (id: string) => {
if (!client) return;
try {
await client.delete(`/posts/${id}`);
queryClient.setQueryData<PostizPost[]>(["posts-list"], (old) =>
(old ?? []).filter((p) => p.id !== id)
);
queryClient.invalidateQueries({ queryKey: ["posts"] });
} catch (e) {
}
};
if (!isConfigured) {
return (
<View
style={[
styles.centered,
{ backgroundColor: colors.background, paddingTop: Platform.OS === "web" ? 67 : 0 },
]}
>
<Feather name="lock" 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
</Text>
</View>
);
}
return (
<View
style={[
styles.container,
{
backgroundColor: colors.background,
paddingTop: Platform.OS === "web" ? 67 : 0,
},
]}
>
<FlatList
horizontal
data={FILTERS}
keyExtractor={(item) => item.key}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterList}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => setFilter(item.key)}
activeOpacity={0.7}
style={[
styles.filterChip,
{
backgroundColor:
filter === item.key ? colors.primary : colors.secondary,
borderColor:
filter === item.key ? colors.primary : colors.border,
},
]}
>
<Text
style={[
styles.filterText,
{
color:
filter === item.key
? colors.primaryForeground
: colors.mutedForeground,
},
]}
>
{item.label}
</Text>
</TouchableOpacity>
)}
style={[styles.filterBar, { borderBottomColor: colors.border }]}
/>
{isLoading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : error ? (
<View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} />
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
Failed to load
</Text>
<TouchableOpacity
onPress={() => refetch()}
style={[styles.retryBtn, { backgroundColor: colors.primary }]}
>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={filteredPosts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<PostCard post={item} onDelete={handleDelete} />
)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyState}>
<Feather name="inbox" size={36} color={colors.mutedForeground} />
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No posts
</Text>
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
{filter === "all"
? "No posts found in the last 3 months"
: `No ${filter.toLowerCase()} posts`}
</Text>
</View>
}
scrollEnabled={filteredPosts.length > 0}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 10,
paddingHorizontal: 32,
},
filterBar: {
borderBottomWidth: StyleSheet.hairlineWidth,
flexGrow: 0,
},
filterList: {
paddingHorizontal: 16,
paddingVertical: 10,
gap: 8,
},
filterChip: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
filterText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
emptyState: {
alignItems: "center",
paddingTop: 64,
gap: 10,
},
emptyTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
emptyText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
retryBtn: {
marginTop: 4,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
retryText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
},
});
@@ -0,0 +1,399 @@
import { Feather } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
import axios from "axios";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/public/v1";
export default function SettingsScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<
"idle" | "ok" | "error"
>("idle");
useEffect(() => {
setInputKey(apiKey);
setInputUrl(baseUrl || DEFAULT_BASE_URL);
}, [apiKey, baseUrl]);
const handleValidate = async () => {
if (!inputKey.trim() || !inputUrl.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL.");
return;
}
setValidating(true);
setValidationStatus("idle");
try {
await axios.get(`${inputUrl.replace(/\/$/, "")}/integrations`, {
headers: { Authorization: inputKey.trim() },
timeout: 10000,
});
setValidationStatus("ok");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch {
setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
} finally {
setValidating(false);
}
};
const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL.");
return;
}
setSaving(true);
try {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, ""));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully.");
} catch {
Alert.alert("Error", "Failed to save settings.");
} finally {
setSaving(false);
}
};
const handleClear = () => {
Alert.alert(
"Disconnect",
"Remove your API key and disconnect from Postiz?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Disconnect",
style: "destructive",
onPress: async () => {
await clearSettings();
setInputKey("");
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
},
},
]
);
};
return (
<KeyboardAwareScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={[
styles.container,
{
paddingTop: Platform.OS === "web" ? 67 : 24,
paddingBottom:
Platform.OS === "web" ? 100 : insets.bottom + 40,
},
]}
bottomOffset={60}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{!isConfigured && (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { color: colors.primary }]}>
Connect to your Postiz instance to get started
</Text>
</View>
)}
{isConfigured && (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz
</Text>
</View>
)}
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>
BASE URL
</Text>
<View
style={[
styles.inputWrap,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/public/v1"
placeholderTextColor={colors.mutedForeground}
value={inputUrl}
onChangeText={(t) => {
setInputUrl(t);
setValidationStatus("idle");
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</View>
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>
API KEY
</Text>
<View
style={[
styles.inputWrap,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={inputKey}
onChangeText={(t) => {
setInputKey(t);
setValidationStatus("idle");
}}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather
name={showKey ? "eye-off" : "eye"}
size={16}
color={colors.mutedForeground}
/>
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validationRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}>
Connection successful
</Text>
</View>
)}
{validationStatus === "error" && (
<View style={styles.validationRow}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.validationText, { color: colors.error }]}>
Could not connect. Check your URL and API key.
</Text>
</View>
)}
</View>
<TouchableOpacity
onPress={handleValidate}
activeOpacity={0.8}
disabled={validating}
style={[
styles.validateBtn,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>
Test Connection
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[
styles.saveBtn,
{ backgroundColor: saving ? colors.muted : colors.primary },
]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings
</Text>
</>
)}
</TouchableOpacity>
{isConfigured && (
<TouchableOpacity
onPress={handleClear}
activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
>
<Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}>
Disconnect
</Text>
</TouchableOpacity>
)}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
Your API key is stored securely on this device and never transmitted to third parties.
</Text>
</View>
</KeyboardAwareScrollView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
gap: 16,
},
banner: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
},
connectedBadge: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
},
connectedText: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
section: {
gap: 8,
},
label: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.8,
marginLeft: 2,
},
inputWrap: {
flexDirection: "row",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
inputIcon: {
flexShrink: 0,
},
input: {
flex: 1,
fontSize: 14,
fontFamily: "Inter_400Regular",
},
validationRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginLeft: 2,
},
validationText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
validateBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
},
saveBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
},
saveText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
clearBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
clearText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
footer: {
marginTop: 8,
},
footerText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
textAlign: "center",
lineHeight: 18,
},
});
@@ -0,0 +1,45 @@
import { Link, Stack } from "expo-router";
import { StyleSheet, Text, View } from "react-native";
import { useColors } from "@/hooks/useColors";
export default function NotFoundScreen() {
const colors = useColors();
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.foreground }]}>
This screen doesn&apos;t exist.
</Text>
<Link href="/" style={styles.link}>
<Text style={[styles.linkText, { color: colors.primary }]}>
Go to home screen!
</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 20,
fontWeight: "bold",
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
},
});
+69
View File
@@ -0,0 +1,69 @@
import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
useFonts,
} from "@expo-google-fonts/inter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import React, { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { PostizProvider } from "@/context/PostizContext";
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30000,
},
},
});
function RootLayoutNav() {
return (
<Stack screenOptions={{ headerBackTitle: "Back" }}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
});
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return null;
return (
<SafeAreaProvider>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<PostizProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<RootLayoutNav />
</KeyboardProvider>
</GestureHandlerRootView>
</PostizProvider>
</QueryClientProvider>
</ErrorBoundary>
</SafeAreaProvider>
);
}