9308fded3e
Original task: Build a downloadable APK so you can install the app on any Android phone.
What was done:
- eas.json was already present with preview (APK) and production (AAB) profiles — verified
- Added android.package "fr.gyozamancave.postizmobile" to app.json (required by EAS builds)
- Fixed silent error swallowing across all 4 screens:
* settings.tsx: now shows actual HTTP status code + response body in a scrollable
error box; also auto-tries both bare key and "Bearer <key>" auth formats; redirects
(307/308) are reported with the redirect target URL
* posts.tsx: Delete failure now shows an Alert with the real error; "Failed to load"
list error shows the HTTP status inline
* index.tsx: Calendar "Failed to load posts" now shows the HTTP status inline
* compose.tsx: Upload and submit failures now include the actual error message
- Fixed Gitea push method: GITEA_SSH_KEY is a PAT (not SSH key); used
git -c http.extraHeader=Authorization: token ... to authenticate and force-pushed
all changes to homegit.gyozamancave.fr/billisdead/Postiz-android.git
Deviations:
- APK not yet built: user has no Expo account (confirmed by user). EAS build requires
a free expo.dev account + interactive eas login. Proposed as follow-up task #7.
- Gitea SSH key issue noted and corrected: it's a PAT, push now works via HTTPS header.
Obsolete follow-up #6 may be retracted since push now works.
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import axios from "axios";
|
|
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 extractError(err: unknown): string {
|
|
if (axios.isAxiosError(err)) {
|
|
const status = err.response?.status;
|
|
const data = err.response?.data;
|
|
if (data) {
|
|
const body =
|
|
typeof data === "string"
|
|
? data.slice(0, 200)
|
|
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
|
|
return status ? `HTTP ${status}: ${body}` : body;
|
|
}
|
|
if (status) return `HTTP ${status} — ${err.message}`;
|
|
if (err.message) return err.message;
|
|
}
|
|
if (err instanceof Error) return err.message;
|
|
return "Unknown error";
|
|
}
|
|
|
|
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>
|
|
<Text style={[styles.emptyText, { color: colors.error, fontSize: 11 }]} selectable>
|
|
{extractError(error)}
|
|
</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",
|
|
},
|
|
});
|