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:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user