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.
284 lines
7.8 KiB
TypeScript
284 lines
7.8 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import axios from "axios";
|
|
import React, { useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
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";
|
|
|
|
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";
|
|
}
|
|
|
|
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: unknown) {
|
|
const msg = extractError(e);
|
|
Alert.alert("Delete failed", msg);
|
|
}
|
|
};
|
|
|
|
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>
|
|
<Text style={[styles.emptyText, { color: colors.mutedForeground }]} selectable>
|
|
{extractError(error)}
|
|
</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",
|
|
},
|
|
});
|