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
+8
View File
@@ -0,0 +1,8 @@
uploads = []
outputs = []
[[generated]]
id = "8iyg9o-DGcn6pChP77Vo1"
uri = "file://artifacts/postiz-mobile/assets/images/icon.png"
type = "image"
title = "generated_image"
+12
View File
@@ -18,3 +18,15 @@ expertMode = true
[postMerge] [postMerge]
path = "scripts/post-merge.sh" path = "scripts/post-merge.sh"
timeoutMs = 20000 timeoutMs = 20000
[[ports]]
localPort = 8080
externalPort = 8080
[[ports]]
localPort = 8081
externalPort = 80
[[ports]]
localPort = 20976
externalPort = 3000
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
ios/
android/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
.vercel
.env
@@ -0,0 +1,27 @@
kind = "mobile"
previewPath = "/"
title = "PostizMobile"
version = "1.0.0"
id = "artifacts/postiz-mobile"
router = "expo-domain"
[[integratedSkills]]
name = "expo"
version = "1.0.0"
[[services]]
ensurePreviewReachable = "/status"
name = "expo"
paths = [ "/" ]
localPort = 20976
[services.development]
run = "pnpm --filter @workspace/postiz-mobile run dev"
[services.production]
build = [ "pnpm", "--filter", "@workspace/postiz-mobile", "run", "build" ]
run = [ "pnpm", "--filter", "@workspace/postiz-mobile", "run", "serve" ]
[services.env]
PORT = "20976"
BASE_PATH = "/"
+50
View File
@@ -0,0 +1,50 @@
{
"expo": {
"name": "PostizMobile",
"slug": "postiz-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "postiz-mobile",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/icon.png",
"resizeMode": "contain",
"backgroundColor": "#0D0D0F"
},
"ios": {
"supportsTablet": false,
"infoPlist": {
"NSPhotoLibraryUsageDescription": "PostizMobile needs access to your photo library to attach images to posts.",
"NSCameraUsageDescription": "PostizMobile needs camera access to take photos for posts."
}
},
"android": {
"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"READ_MEDIA_IMAGES"
]
},
"web": {
"favicon": "./assets/images/icon.png"
},
"plugins": [
[
"expo-router",
{
"origin": "https://replit.com/"
}
],
"expo-font",
"expo-web-browser",
"expo-image-picker",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}
@@ -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>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
};
};
@@ -0,0 +1,84 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useColors } from "@/hooks/useColors";
import { PostizIntegration } from "@/context/PostizContext";
interface ChannelChipProps {
integration: PostizIntegration;
selected: boolean;
onToggle: () => void;
}
export function ChannelChip({ integration, selected, onToggle }: ChannelChipProps) {
const colors = useColors();
return (
<TouchableOpacity
onPress={onToggle}
activeOpacity={0.7}
style={[
styles.chip,
{
backgroundColor: selected ? colors.primary + "20" : colors.card,
borderColor: selected ? colors.primary : colors.border,
},
]}
>
{integration.picture ? (
<Image
source={{ uri: integration.picture }}
style={styles.avatar}
contentFit="cover"
/>
) : (
<View style={[styles.avatarFallback, { backgroundColor: colors.secondary }]}>
<Feather name="globe" size={12} color={colors.mutedForeground} />
</View>
)}
<Text
style={[
styles.name,
{ color: selected ? colors.primary : colors.foreground },
]}
numberOfLines={1}
>
{integration.name}
</Text>
{selected && (
<Feather name="check" size={12} color={colors.primary} />
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
chip: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
gap: 6,
maxWidth: 150,
},
avatar: {
width: 20,
height: 20,
borderRadius: 10,
},
avatarFallback: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
name: {
fontSize: 12,
fontFamily: "Inter_500Medium",
flexShrink: 1,
},
});
@@ -0,0 +1,54 @@
import React, { Component, ComponentType, PropsWithChildren } from "react";
import { ErrorFallback, ErrorFallbackProps } from "@/components/ErrorFallback";
export type ErrorBoundaryProps = PropsWithChildren<{
FallbackComponent?: ComponentType<ErrorFallbackProps>;
onError?: (error: Error, stackTrace: string) => void;
}>;
type ErrorBoundaryState = { error: Error | null };
/**
* This is a special case for for using the class components. Error boundaries must be class components because React only provides error boundary functionality through lifecycle methods (componentDidCatch and getDerivedStateFromError) which are not available in functional components.
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static defaultProps: {
FallbackComponent: ComponentType<ErrorFallbackProps>;
} = {
FallbackComponent: ErrorFallback,
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
if (typeof this.props.onError === "function") {
this.props.onError(error, info.componentStack);
}
}
resetError = (): void => {
this.setState({ error: null });
};
render() {
const { FallbackComponent } = this.props;
return this.state.error && FallbackComponent ? (
<FallbackComponent
error={this.state.error}
resetError={this.resetError}
/>
) : (
this.props.children
);
}
}
@@ -0,0 +1,278 @@
import { Feather } from "@expo/vector-icons";
import { reloadAppAsync } from "expo";
import React, { useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
export type ErrorFallbackProps = {
error: Error;
resetError: () => void;
};
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [isModalVisible, setIsModalVisible] = useState(false);
const handleRestart = async () => {
try {
await reloadAppAsync();
} catch (restartError) {
console.error("Failed to restart app:", restartError);
resetError();
}
};
const formatErrorDetails = (): string => {
let details = `Error: ${error.message}\n\n`;
if (error.stack) {
details += `Stack Trace:\n${error.stack}`;
}
return details;
};
const monoFont = Platform.select({
ios: "Menlo",
android: "monospace",
default: "monospace",
});
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
{__DEV__ ? (
<Pressable
onPress={() => setIsModalVisible(true)}
accessibilityLabel="View error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.topButton,
{
top: insets.top + 16,
backgroundColor: colors.card,
opacity: pressed ? 0.8 : 1,
},
]}
>
<Feather name="alert-circle" size={20} color={colors.foreground} />
</Pressable>
) : null}
<View style={styles.content}>
<Text style={[styles.title, { color: colors.foreground }]}>
Something went wrong
</Text>
<Text style={[styles.message, { color: colors.mutedForeground }]}>
Please reload the app to continue.
</Text>
<Pressable
onPress={handleRestart}
style={({ pressed }) => [
styles.button,
{
backgroundColor: colors.primary,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text
style={[
styles.buttonText,
{ color: colors.primaryForeground },
]}
>
Try Again
</Text>
</Pressable>
</View>
{__DEV__ ? (
<Modal
visible={isModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setIsModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: colors.background },
]}
>
<View
style={[
styles.modalHeader,
{ borderBottomColor: colors.border },
]}
>
<Text style={[styles.modalTitle, { color: colors.foreground }]}>
Error Details
</Text>
<Pressable
onPress={() => setIsModalVisible(false)}
accessibilityLabel="Close error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.closeButton,
{ opacity: pressed ? 0.6 : 1 },
]}
>
<Feather name="x" size={24} color={colors.foreground} />
</Pressable>
</View>
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={[
styles.modalScrollContent,
{ paddingBottom: insets.bottom + 16 },
]}
showsVerticalScrollIndicator
>
<View
style={[
styles.errorContainer,
{ backgroundColor: colors.card },
]}
>
<Text
style={[
styles.errorText,
{
color: colors.foreground,
fontFamily: monoFont,
},
]}
selectable
>
{formatErrorDetails()}
</Text>
</View>
</ScrollView>
</View>
</View>
</Modal>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: 24,
},
content: {
alignItems: "center",
justifyContent: "center",
gap: 16,
width: "100%",
maxWidth: 600,
},
title: {
fontSize: 28,
fontWeight: "700",
textAlign: "center",
lineHeight: 40,
},
message: {
fontSize: 16,
textAlign: "center",
lineHeight: 24,
},
topButton: {
position: "absolute",
right: 16,
width: 44,
height: 44,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
},
button: {
paddingVertical: 16,
borderRadius: 8,
paddingHorizontal: 24,
minWidth: 200,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
buttonText: {
fontWeight: "600",
textAlign: "center",
fontSize: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
width: "100%",
height: "90%",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 12,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
},
closeButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
},
modalScrollView: {
flex: 1,
},
modalScrollContent: {
padding: 16,
},
errorContainer: {
width: "100%",
borderRadius: 8,
overflow: "hidden",
padding: 16,
},
errorText: {
fontSize: 12,
lineHeight: 18,
width: "100%",
},
});
@@ -0,0 +1,29 @@
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
} from "react-native-keyboard-controller";
import { Platform, ScrollView, ScrollViewProps } from "react-native";
type Props = KeyboardAwareScrollViewProps & ScrollViewProps;
export function KeyboardAwareScrollViewCompat({
children,
keyboardShouldPersistTaps = "handled",
...props
}: Props) {
if (Platform.OS === "web") {
return (
<ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} {...props}>
{children}
</ScrollView>
);
}
return (
<KeyboardAwareScrollView
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
{...props}
>
{children}
</KeyboardAwareScrollView>
);
}
@@ -0,0 +1,195 @@
import { Feather } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import React, { useRef } from "react";
import {
Alert,
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useColors } from "@/hooks/useColors";
import { PostizPost } from "@/context/PostizContext";
import { StatusBadge } from "./StatusBadge";
interface PostCardProps {
post: PostizPost;
onDelete: (id: string) => Promise<void>;
}
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}
function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["name"] {
const t = (type ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x")) return "twitter";
if (t.includes("linkedin")) return "linkedin";
if (t.includes("instagram")) return "instagram";
if (t.includes("facebook")) return "facebook";
if (t.includes("youtube")) return "youtube";
return "globe";
}
export function PostCard({ post, onDelete }: PostCardProps) {
const colors = useColors();
const swipeRef = useRef<Swipeable>(null);
const handleDelete = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
Alert.alert("Delete Post", "Are you sure you want to delete this post?", [
{
text: "Cancel",
style: "cancel",
onPress: () => swipeRef.current?.close(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
await onDelete(post.id);
},
},
]);
};
const renderRightActions = (
_progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const scale = dragX.interpolate({
inputRange: [-80, 0],
outputRange: [1, 0.8],
extrapolate: "clamp",
});
return (
<TouchableOpacity
style={[styles.deleteAction, { backgroundColor: colors.destructive }]}
onPress={handleDelete}
activeOpacity={0.8}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Feather name="trash-2" size={20} color="#fff" />
</Animated.View>
</TouchableOpacity>
);
};
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const truncatedContent =
post.content.length > 140
? post.content.slice(0, 140) + "…"
: post.content;
return (
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
rightThreshold={40}
friction={2}
>
<View
style={[
styles.card,
{ backgroundColor: colors.card, borderBottomColor: colors.border },
]}
>
<View style={styles.header}>
<View style={styles.integrations}>
{integrations.slice(0, 3).map((intg) => (
<View
key={intg.id}
style={[
styles.networkIcon,
{ backgroundColor: colors.secondary },
]}
>
<Feather
name={getNetworkIcon(intg.type ?? intg.internalType)}
size={12}
color={colors.mutedForeground}
/>
</View>
))}
{integrations.length > 3 && (
<Text style={[styles.moreText, { color: colors.mutedForeground }]}>
+{integrations.length - 3}
</Text>
)}
</View>
<StatusBadge status={post.status} />
</View>
<Text style={[styles.content, { color: colors.foreground }]}>
{truncatedContent}
</Text>
<View style={styles.footer}>
<Feather name="clock" size={12} color={colors.mutedForeground} />
<Text style={[styles.date, { color: colors.mutedForeground }]}>
{formatDate(post.publishDate)}
</Text>
</View>
</View>
</Swipeable>
);
}
const styles = StyleSheet.create({
card: {
paddingHorizontal: 20,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
integrations: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
networkIcon: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
},
moreText: {
fontSize: 11,
fontFamily: "Inter_500Medium",
},
content: {
fontSize: 14,
fontFamily: "Inter_400Regular",
lineHeight: 20,
},
footer: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
date: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
deleteAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
});
@@ -0,0 +1,50 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { useColors } from "@/hooks/useColors";
type PostStatus = "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
interface StatusBadgeProps {
status: PostStatus;
}
export function StatusBadge({ status }: StatusBadgeProps) {
const colors = useColors();
const config: Record<PostStatus, { bg: string; text: string; label: string }> = {
QUEUE: { bg: colors.warning + "25", text: colors.warning, label: "Queue" },
PUBLISHED: { bg: colors.success + "25", text: colors.success, label: "Published" },
ERROR: { bg: colors.error + "25", text: colors.error, label: "Error" },
DRAFT: { bg: colors.muted, text: colors.mutedForeground, label: "Draft" },
};
const { bg, text, label } = config[status] ?? config.DRAFT;
return (
<View style={[styles.badge, { backgroundColor: bg }]}>
<View style={[styles.dot, { backgroundColor: text }]} />
<Text style={[styles.label, { color: text }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 20,
gap: 4,
},
dot: {
width: 5,
height: 5,
borderRadius: 3,
},
label: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.3,
},
});
@@ -0,0 +1,51 @@
const colors = {
light: {
text: "#F5F5F7",
tint: "#6366F1",
background: "#0D0D0F",
foreground: "#F5F5F7",
card: "#161619",
cardForeground: "#F5F5F7",
primary: "#6366F1",
primaryForeground: "#FFFFFF",
secondary: "#1E1E26",
secondaryForeground: "#F5F5F7",
muted: "#1E1E26",
mutedForeground: "#8E8E9A",
accent: "#6366F1",
accentForeground: "#FFFFFF",
destructive: "#EF4444",
destructiveForeground: "#FFFFFF",
border: "#252530",
input: "#1A1A22",
success: "#10B981",
warning: "#F59E0B",
error: "#EF4444",
},
dark: {
text: "#F5F5F7",
tint: "#6366F1",
background: "#0D0D0F",
foreground: "#F5F5F7",
card: "#161619",
cardForeground: "#F5F5F7",
primary: "#6366F1",
primaryForeground: "#FFFFFF",
secondary: "#1E1E26",
secondaryForeground: "#F5F5F7",
muted: "#1E1E26",
mutedForeground: "#8E8E9A",
accent: "#6366F1",
accentForeground: "#FFFFFF",
destructive: "#EF4444",
destructiveForeground: "#FFFFFF",
border: "#252530",
input: "#1A1A22",
success: "#10B981",
warning: "#F59E0B",
error: "#EF4444",
},
radius: 12,
};
export default colors;
@@ -0,0 +1,138 @@
import axios, { AxiosInstance } from "axios";
import * as SecureStore from "expo-secure-store";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
const API_KEY_STORAGE = "postiz_api_key";
const BASE_URL_STORAGE = "postiz_base_url";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/public/v1";
export interface PostizIntegration {
id: string;
name: string;
type: string;
picture?: string;
identifier?: string;
internalType?: string;
}
export interface PostizMediaItem {
id: string;
path: string;
}
export interface PostizPost {
id: string;
content: string;
status: "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
publishDate: string;
integration?: PostizIntegration;
integrations?: PostizIntegration[];
image?: PostizMediaItem[];
group?: string;
}
export interface PostizUploadResult {
id: string;
path: string;
}
interface PostizContextValue {
apiKey: string;
baseUrl: string;
isConfigured: boolean;
isLoading: boolean;
client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>;
}
const PostizContext = createContext<PostizContextValue>({
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
isConfigured: false,
isLoading: true,
client: null,
saveSettings: async () => {},
clearSettings: async () => {},
});
function createClient(apiKey: string, baseUrl: string): AxiosInstance {
return axios.create({
baseURL: baseUrl,
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
timeout: 15000,
});
}
export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
useEffect(() => {
(async () => {
try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE);
if (storedKey) {
const url = storedUrl || DEFAULT_BASE_URL;
setApiKey(storedKey);
setBaseUrl(url);
setClient(createClient(storedKey, url));
}
} catch {
} finally {
setIsLoading(false);
}
})();
}, []);
const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => {
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey);
setBaseUrl(newBaseUrl);
setClient(createClient(newApiKey, newBaseUrl));
},
[]
);
const clearSettings = useCallback(async () => {
await SecureStore.deleteItemAsync(API_KEY_STORAGE);
await SecureStore.deleteItemAsync(BASE_URL_STORAGE);
setApiKey("");
setBaseUrl(DEFAULT_BASE_URL);
setClient(null);
}, []);
return (
<PostizContext.Provider
value={{
apiKey,
baseUrl,
isConfigured: !!apiKey,
isLoading,
client,
saveSettings,
clearSettings,
}}
>
{children}
</PostizContext.Provider>
);
}
export function usePostiz() {
return useContext(PostizContext);
}
@@ -0,0 +1,24 @@
import { useColorScheme } from "react-native";
import colors from "@/constants/colors";
/**
* Returns the design tokens for the current color scheme.
*
* The returned object contains all color tokens for the active palette
* plus scheme-independent values like `radius`.
*
* Falls back to the light palette when no dark key is defined in
* constants/colors.ts (the scaffold ships light-only by default).
* When a sibling web artifact's dark tokens are synced into a `dark`
* key, this hook will automatically switch palettes based on the
* device's appearance setting.
*/
export function useColors() {
const scheme = useColorScheme();
const palette =
scheme === "dark" && "dark" in colors
? (colors as Record<string, typeof colors.light>).dark
: colors.light;
return { ...palette, radius: colors.radius };
}
+3
View File
@@ -0,0 +1,3 @@
const { getDefaultConfig } = require("expo/metro-config");
module.exports = getDefaultConfig(__dirname);
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@workspace/postiz-mobile",
"version": "0.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "EXPO_PACKAGER_PROXY_URL=https://$REPLIT_EXPO_DEV_DOMAIN EXPO_PUBLIC_DOMAIN=$REPLIT_DEV_DOMAIN EXPO_PUBLIC_REPL_ID=$REPL_ID REACT_NATIVE_PACKAGER_HOSTNAME=$REPLIT_DEV_DOMAIN pnpm exec expo start --localhost --port $PORT",
"build": "node scripts/build.js",
"serve": "node server/serve.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@expo-google-fonts/inter": "^0.4.0",
"@expo/cli": "54.0.23",
"@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@stardazed/streams-text-encoding": "^1.0.2",
"@tanstack/react-query": "catalog:",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"@ungap/structured-clone": "^1.3.0",
"@workspace/api-client-react": "workspace:*",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117",
"expo": "~54.0.27",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.11",
"expo-font": "~14.0.10",
"expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.9",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-location": "~19.0.8",
"expo-router": "~6.0.17",
"expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"typescript": "~5.9.2",
"zod": "catalog:",
"zod-validation-error": "^3.4.0"
},
"dependencies": {
"@react-native-community/datetimepicker": "^9.1.0",
"axios": "^1.15.2",
"expo-secure-store": "^55.0.13",
"react-native-calendars": "^1.1314.0"
}
}
+573
View File
@@ -0,0 +1,573 @@
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { Readable } = require("stream");
const { pipeline } = require("stream/promises");
let metroProcess = null;
const projectRoot = path.resolve(__dirname, "..");
function findWorkspaceRoot(startDir) {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error("Could not find workspace root (no pnpm-workspace.yaml found)");
}
const workspaceRoot = findWorkspaceRoot(projectRoot);
const basePath = (process.env.BASE_PATH || "/").replace(/\/+$/, "");
function exitWithError(message) {
console.error(message);
if (metroProcess) {
metroProcess.kill();
}
process.exit(1);
}
function setupSignalHandlers() {
const cleanup = () => {
if (metroProcess) {
console.log("Cleaning up Metro process...");
metroProcess.kill();
}
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGHUP", cleanup);
}
function stripProtocol(domain) {
let urlString = domain.trim();
if (!/^https?:\/\//i.test(urlString)) {
urlString = `https://${urlString}`;
}
return new URL(urlString).host;
}
function getDeploymentDomain() {
if (process.env.REPLIT_INTERNAL_APP_DOMAIN) {
return stripProtocol(process.env.REPLIT_INTERNAL_APP_DOMAIN);
}
if (process.env.REPLIT_DEV_DOMAIN) {
return stripProtocol(process.env.REPLIT_DEV_DOMAIN);
}
if (process.env.EXPO_PUBLIC_DOMAIN) {
return stripProtocol(process.env.EXPO_PUBLIC_DOMAIN);
}
console.error(
"ERROR: No deployment domain found. Set REPLIT_INTERNAL_APP_DOMAIN, REPLIT_DEV_DOMAIN, or EXPO_PUBLIC_DOMAIN",
);
process.exit(1);
}
function prepareDirectories(timestamp) {
console.log("Preparing build directories...");
const staticBuild = path.join(projectRoot, "static-build");
if (fs.existsSync(staticBuild)) {
fs.rmSync(staticBuild, { recursive: true });
}
const dirs = [
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios"),
path.join(staticBuild, timestamp, "_expo", "static", "js", "android"),
path.join(staticBuild, "ios"),
path.join(staticBuild, "android"),
];
for (const dir of dirs) {
fs.mkdirSync(dir, { recursive: true });
}
console.log("Build:", timestamp);
}
function clearMetroCache() {
console.log("Clearing Metro cache...");
const cacheDirs = [
path.join(projectRoot, ".metro-cache"),
path.join(projectRoot, "node_modules/.cache/metro"),
];
for (const dir of cacheDirs) {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log("Cache cleared");
}
async function checkMetroHealth() {
try {
const response = await fetch("http://localhost:8081/status", {
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
function getExpoPublicReplId() {
return process.env.REPL_ID || process.env.EXPO_PUBLIC_REPL_ID;
}
async function startMetro(expoPublicDomain, expoPublicReplId) {
const isRunning = await checkMetroHealth();
if (isRunning) {
console.log("Metro already running");
return;
}
console.log("Starting Metro...");
console.log(`Setting EXPO_PUBLIC_DOMAIN=${expoPublicDomain}`);
const env = {
...process.env,
EXPO_PUBLIC_DOMAIN: expoPublicDomain,
EXPO_PUBLIC_REPL_ID: expoPublicReplId,
};
if (expoPublicReplId) {
console.log(`Setting EXPO_PUBLIC_REPL_ID=${expoPublicReplId}`);
}
metroProcess = spawn(
"pnpm",
[
"exec",
"expo",
"start",
"--no-dev",
"--minify",
"--localhost",
],
{
stdio: ["ignore", "pipe", "pipe"],
detached: false,
cwd: projectRoot,
env,
},
);
if (metroProcess.stdout) {
metroProcess.stdout.on("data", (data) => {
const output = data.toString().trim();
if (output) console.log(`[Metro] ${output}`);
});
}
if (metroProcess.stderr) {
metroProcess.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output) console.error(`[Metro Error] ${output}`);
});
}
for (let i = 0; i < 60; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const healthy = await checkMetroHealth();
if (healthy) {
console.log("Metro ready");
return;
}
}
console.error("Metro timeout");
process.exit(1);
}
async function downloadFile(url, outputPath) {
const controller = new AbortController();
const fiveMinMS = 5 * 60 * 1_000;
const timeoutId = setTimeout(() => controller.abort(), fiveMinMS);
try {
console.log(`Downloading: ${url}`);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const file = fs.createWriteStream(outputPath);
await pipeline(Readable.fromWeb(response.body), file);
const fileSize = fs.statSync(outputPath).size;
if (fileSize === 0) {
fs.unlinkSync(outputPath);
throw new Error("Downloaded file is empty");
}
} catch (error) {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
if (error.name === "AbortError") {
throw new Error(`Download timeout after 5m: ${url}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async function downloadBundle(platform, timestamp) {
const entryPath = path.resolve(projectRoot, "node_modules", "expo-router", "entry");
const bundlePath = path.relative(workspaceRoot, entryPath);
const url = new URL(`http://localhost:8081/${bundlePath}.bundle`);
url.searchParams.set("platform", platform);
url.searchParams.set("dev", "false");
url.searchParams.set("hot", "false");
url.searchParams.set("lazy", "false");
url.searchParams.set("minify", "true");
const output = path.join(
"static-build",
timestamp,
"_expo",
"static",
"js",
platform,
"bundle.js",
);
console.log(`Fetching ${platform} bundle...`);
await downloadFile(url.toString(), output);
console.log(`${platform} bundle ready`);
}
async function downloadManifest(platform) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300_000);
try {
console.log(`Fetching ${platform} manifest...`);
const response = await fetch("http://localhost:8081/manifest", {
headers: { "expo-platform": platform },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const manifest = await response.json();
console.log(`${platform} manifest ready`);
return manifest;
} catch (error) {
if (error.name === "AbortError") {
throw new Error(
`Manifest download timeout after 5m for platform: ${platform}`,
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async function downloadBundlesAndManifests(timestamp) {
console.log("Downloading bundles and manifests...");
console.log("This may take several minutes for production builds...");
try {
// Bundles are sequential — Metro can't handle both platforms simultaneously
// without stalling. Manifests are cheap and run in parallel after.
await downloadBundle("ios", timestamp);
await downloadBundle("android", timestamp);
const [iosManifest, androidManifest] = await Promise.all([
downloadManifest("ios"),
downloadManifest("android"),
]);
console.log("All downloads completed successfully");
return { ios: iosManifest, android: androidManifest };
} catch (error) {
exitWithError(`Download failed: ${error.message}`);
}
}
function extractAssets(timestamp) {
const staticBuild = path.join(projectRoot, "static-build");
const bundles = {
ios: fs.readFileSync(
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios", "bundle.js"),
"utf-8",
),
android: fs.readFileSync(
path.join(staticBuild, timestamp, "_expo", "static", "js", "android", "bundle.js"),
"utf-8",
),
};
const assetsMap = new Map();
const assetPattern =
/httpServerLocation:"([^"]+)"[^}]*hash:"([^"]+)"[^}]*name:"([^"]+)"[^}]*type:"([^"]+)"/g;
const extractFromBundle = (bundle, platform) => {
for (const match of bundle.matchAll(assetPattern)) {
const originalPath = match[1];
const filename = match[3] + "." + match[4];
const tempUrl = new URL(`http://localhost:8081${originalPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(`Asset missing unstable_path: ${originalPath}`);
}
const decodedPath = decodeURIComponent(unstablePath);
const key = path.posix.join(decodedPath, filename);
if (!assetsMap.has(key)) {
const asset = {
url: path.posix.join("/", decodedPath, filename),
originalPath: originalPath,
filename: filename,
relativePath: decodedPath,
hash: match[2],
platforms: new Set(),
};
assetsMap.set(key, asset);
}
assetsMap.get(key).platforms.add(platform);
}
};
extractFromBundle(bundles.ios, "ios");
extractFromBundle(bundles.android, "android");
return Array.from(assetsMap.values());
}
async function downloadAssets(assets, timestamp) {
if (assets.length === 0) {
return 0;
}
console.log("Copying assets...");
let successCount = 0;
const failures = [];
const downloadPromises = assets.map(async (asset) => {
const tempUrl = new URL(`http://localhost:8081${asset.originalPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(`Asset missing unstable_path: ${asset.originalPath}`);
}
const decodedPath = decodeURIComponent(unstablePath);
const outputDir = path.join(
projectRoot,
"static-build",
timestamp,
"_expo",
"static",
"js",
asset.relativePath,
);
fs.mkdirSync(outputDir, { recursive: true });
const output = path.join(outputDir, asset.filename);
try {
const candidates = [
path.join(projectRoot, decodedPath, asset.filename),
path.join(workspaceRoot, decodedPath, asset.filename),
];
const found = candidates.find((p) => fs.existsSync(p));
if (!found) {
throw new Error(`Asset not found on disk: ${asset.filename}`);
}
fs.copyFileSync(found, output);
successCount++;
} catch (error) {
failures.push({
filename: asset.filename,
error: error.message,
url: asset.originalPath,
});
}
});
await Promise.all(downloadPromises);
if (failures.length > 0) {
const errorMsg =
`Failed to download ${failures.length} asset(s):\n` +
failures
.map((f) => ` - ${f.filename}: ${f.error} (${f.url})`)
.join("\n");
exitWithError(errorMsg);
}
console.log(`Copied ${successCount} assets`);
return successCount;
}
function updateBundleUrls(timestamp, baseUrl) {
const updateForPlatform = (platform) => {
const bundlePath = path.join(
projectRoot,
"static-build",
timestamp,
"_expo",
"static",
"js",
platform,
"bundle.js",
);
let bundle = fs.readFileSync(bundlePath, "utf-8");
bundle = bundle.replace(
/httpServerLocation:"(\/[^"]+)"/g,
(_match, capturedPath) => {
const tempUrl = new URL(`http://localhost:8081${capturedPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(
`Asset missing unstable_path in bundle: ${capturedPath}`,
);
}
const decodedPath = decodeURIComponent(unstablePath);
return `httpServerLocation:"${baseUrl}${basePath}/${timestamp}/_expo/static/js/${decodedPath}"`;
},
);
fs.writeFileSync(bundlePath, bundle);
};
updateForPlatform("ios");
updateForPlatform("android");
console.log("Updated bundle URLs");
}
function updateManifests(manifests, timestamp, baseUrl, assetsByHash) {
const updateForPlatform = (platform, manifest) => {
if (!manifest.launchAsset || !manifest.extra) {
exitWithError(`Malformed manifest for ${platform}`);
}
manifest.launchAsset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${platform}/bundle.js`;
manifest.launchAsset.key = `bundle-${timestamp}`;
manifest.createdAt = new Date(
Number(timestamp.split("-")[0]),
).toISOString();
manifest.extra.expoClient.hostUri =
baseUrl.replace("https://", "") + "/" + platform;
manifest.extra.expoGo.debuggerHost =
baseUrl.replace("https://", "") + "/" + platform;
manifest.extra.expoGo.packagerOpts.dev = false;
if (manifest.assets && manifest.assets.length > 0) {
manifest.assets.forEach((asset) => {
if (!asset.url) return;
const hash = asset.hash;
if (!hash) return;
const assetInfo = assetsByHash.get(hash);
if (!assetInfo) return;
asset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${assetInfo.relativePath}/${assetInfo.filename}`;
});
}
fs.writeFileSync(
path.join(projectRoot, "static-build", platform, "manifest.json"),
JSON.stringify(manifest, null, 2),
);
};
updateForPlatform("ios", manifests.ios);
updateForPlatform("android", manifests.android);
console.log("Manifests updated");
}
async function main() {
console.log("Building static Expo Go deployment...");
setupSignalHandlers();
const domain = getDeploymentDomain();
const expoPublicReplId = getExpoPublicReplId();
const baseUrl = `https://${domain}`;
const timestamp = `${Date.now()}-${process.pid}`;
prepareDirectories(timestamp);
clearMetroCache();
await startMetro(domain, expoPublicReplId);
const downloadTimeout = 600000;
const downloadPromise = downloadBundlesAndManifests(timestamp);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Overall download timeout after ${downloadTimeout / 1000} seconds. ` +
"Metro may be struggling to generate bundles. Check Metro logs above.",
),
);
}, downloadTimeout);
});
const manifests = await Promise.race([downloadPromise, timeoutPromise]);
console.log("Processing assets...");
const assets = extractAssets(timestamp);
console.log("Found", assets.length, "unique asset(s)");
const assetsByHash = new Map();
for (const asset of assets) {
assetsByHash.set(asset.hash, {
relativePath: asset.relativePath,
filename: asset.filename,
});
}
const assetCount = await downloadAssets(assets, timestamp);
if (assetCount > 0) {
updateBundleUrls(timestamp, baseUrl);
}
console.log("Updating manifests and creating landing page...");
updateManifests(manifests, timestamp, baseUrl, assetsByHash);
console.log("Build complete! Deploy to:", baseUrl);
if (metroProcess) {
metroProcess.kill();
}
process.exit(0);
}
main().catch((error) => {
console.error("Build failed:", error.message);
if (metroProcess) {
metroProcess.kill();
}
process.exit(1);
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Standalone production server for Expo static builds.
*
* Serves the output of build.js (static-build/) with two special routes:
* - GET / or /manifest with expo-platform header platform manifest JSON
* - GET / without expo-platform landing page HTML
* Everything else falls through to static file serving from ./static-build/.
*
* Zero external dependencies uses only Node.js built-ins (http, fs, path).
*/
const http = require("http");
const fs = require("fs");
const path = require("path");
const STATIC_ROOT = path.resolve(__dirname, "..", "static-build");
const TEMPLATE_PATH = path.resolve(__dirname, "templates", "landing-page.html");
const basePath = (process.env.BASE_PATH || "/").replace(/\/+$/, "");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".map": "application/json",
};
function getAppName() {
try {
const appJsonPath = path.resolve(__dirname, "..", "app.json");
const appJson = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
return appJson.expo?.name || "App Landing Page";
} catch {
return "App Landing Page";
}
}
function serveManifest(platform, res) {
const manifestPath = path.join(STATIC_ROOT, platform, "manifest.json");
if (!fs.existsSync(manifestPath)) {
res.writeHead(404, { "content-type": "application/json" });
res.end(
JSON.stringify({ error: `Manifest not found for platform: ${platform}` }),
);
return;
}
const manifest = fs.readFileSync(manifestPath, "utf-8");
res.writeHead(200, {
"content-type": "application/json",
"expo-protocol-version": "1",
"expo-sfv-version": "0",
});
res.end(manifest);
}
function serveLandingPage(req, res, landingPageTemplate, appName) {
const forwardedProto = req.headers["x-forwarded-proto"];
const protocol = forwardedProto || "https";
const host = req.headers["x-forwarded-host"] || req.headers["host"];
const baseUrl = `${protocol}://${host}`;
const expsUrl = `${host}`;
const html = landingPageTemplate
.replace(/BASE_URL_PLACEHOLDER/g, baseUrl)
.replace(/EXPS_URL_PLACEHOLDER/g, expsUrl)
.replace(/APP_NAME_PLACEHOLDER/g, appName);
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
}
function serveStaticFile(urlPath, res) {
const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, "");
const filePath = path.join(STATIC_ROOT, safePath);
if (!filePath.startsWith(STATIC_ROOT)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
res.writeHead(404);
res.end("Not Found");
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
const content = fs.readFileSync(filePath);
res.writeHead(200, { "content-type": contentType });
res.end(content);
}
const landingPageTemplate = fs.readFileSync(TEMPLATE_PATH, "utf-8");
const appName = getAppName();
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
let pathname = url.pathname;
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/";
}
if (pathname === "/" || pathname === "/manifest") {
const platform = req.headers["expo-platform"];
if (platform === "ios" || platform === "android") {
return serveManifest(platform, res);
}
if (pathname === "/") {
return serveLandingPage(req, res, landingPageTemplate, appName);
}
}
serveStaticFile(pathname, res);
});
const port = parseInt(process.env.PORT || "3000", 10);
server.listen(port, "0.0.0.0", () => {
console.log(`Serving static Expo build on port ${port}`);
});
@@ -0,0 +1,460 @@
<!doctype html>
<html>
<head>
<title>APP_NAME_PLACEHOLDER</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='180' height='180' rx='36' fill='%23FF3C00'/%3E%3C/svg%3E" />
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 32px 20px;
text-align: center;
background: #fff;
color: #222;
line-height: 1.5;
min-height: 100vh;
}
.wrapper {
max-width: 480px;
margin: 0 auto;
}
h1 {
font-size: 26px;
font-weight: 600;
margin: 0;
color: #111;
}
.subtitle {
font-size: 15px;
color: #666;
margin-top: 8px;
margin-bottom: 32px;
}
.loading {
display: none;
margin: 60px 0;
}
.spinner {
border: 2px solid #ddd;
border-top-color: #333;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 0.8s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 16px;
color: #444;
}
.content {
display: block;
}
.steps-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.step {
padding: 24px;
border: 1px solid #ddd;
border-radius: 12px;
text-align: center;
background: #fafafa;
}
.step-header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 12px;
}
.step-number {
width: 28px;
height: 28px;
border: 1px solid #999;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
color: #555;
}
.step-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #222;
}
.step-description {
font-size: 14px;
margin-bottom: 16px;
color: #666;
}
.store-buttons {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.store-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
border: 1px solid #ccc;
border-radius: 8px;
text-decoration: none;
color: #333;
background: #fff;
transition: all 0.15s;
}
.store-button:hover {
background: #f5f5f5;
border-color: #999;
}
.store-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 0;
font-size: 13px;
font-weight: 400;
text-decoration: underline;
text-underline-offset: 2px;
color: #666;
background: none;
border: none;
transition: color 0.15s;
}
.store-link:hover {
color: #333;
}
.store-link .store-icon {
width: 14px;
height: 14px;
}
.store-icon {
width: 18px;
height: 18px;
}
.qr-section {
background: #333;
color: #fff;
border-color: #333;
}
.qr-section .step-number {
border-color: rgba(255, 255, 255, 0.5);
color: #fff;
}
.qr-section .step-title {
color: #fff;
}
.qr-section .step-description {
color: rgba(255, 255, 255, 0.7);
}
.qr-code {
width: 180px;
height: 180px;
margin: 0 auto 16px;
background: #fff;
border-radius: 8px;
padding: 12px;
}
.qr-code canvas {
width: 100%;
height: 100%;
}
.open-button {
display: inline-block;
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
text-decoration: none;
color: #333;
background: #fff;
transition: opacity 0.15s;
}
.open-button:hover {
opacity: 0.9;
}
/* Desktop styles */
@media (min-width: 768px) {
body {
padding: 48px 32px;
display: flex;
align-items: center;
justify-content: center;
}
.wrapper {
max-width: 720px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
margin-bottom: 40px;
}
.steps-container {
flex-direction: row;
gap: 20px;
align-items: stretch;
}
.step {
flex: 1;
display: flex;
flex-direction: column;
padding: 28px;
}
.step-description {
flex-grow: 1;
}
.store-buttons {
flex-direction: column;
gap: 10px;
}
.qr-code {
width: 200px;
height: 200px;
}
}
/* Large desktop */
@media (min-width: 1024px) {
.wrapper {
max-width: 800px;
}
h1 {
font-size: 36px;
}
.steps-container {
gap: 28px;
}
.step {
padding: 32px;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #0d0d0d;
color: #e0e0e0;
}
h1 {
color: #f5f5f5;
}
.subtitle {
color: #999;
}
.spinner {
border-color: #444;
border-top-color: #ccc;
}
.loading-text {
color: #aaa;
}
.step {
border-color: #333;
background: #1a1a1a;
}
.step-number {
border-color: #666;
color: #bbb;
}
.step-title {
color: #f0f0f0;
}
.step-description {
color: #888;
}
.store-button {
border-color: #444;
color: #e0e0e0;
background: #222;
}
.store-button:hover {
background: #2a2a2a;
border-color: #666;
}
.store-link {
color: #888;
}
.store-link:hover {
color: #ccc;
}
.qr-section {
background: #111;
border-color: #333;
}
.qr-section .step-number {
border-color: rgba(255, 255, 255, 0.4);
}
.qr-section .step-description {
color: rgba(255, 255, 255, 0.6);
}
.open-button {
background: #f0f0f0;
color: #111;
}
.open-button:hover {
background: #e0e0e0;
}
}
</style>
</head>
<body>
<div class="wrapper">
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-text">Opening in Expo Go...</div>
</div>
<div class="content" id="content">
<h1>APP_NAME_PLACEHOLDER</h1>
<p class="subtitle">Preview this app on your phone</p>
<div class="steps-container">
<div class="step">
<div class="step-header">
<div class="step-number">1</div>
<h2 class="step-title">Download Expo Go</h2>
</div>
<p class="step-description">
Expo Go is a free app to test mobile apps
</p>
<div class="store-buttons" id="store-buttons">
<a
id="app-store-btn"
href="https://apps.apple.com/app/id982107779"
class="store-button"
target="_blank"
>
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
/>
</svg>
App Store
</a>
<a
id="play-store-btn"
href="https://play.google.com/store/apps/details?id=host.exp.exponent"
class="store-button"
target="_blank"
>
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"
/>
</svg>
Google Play
</a>
</div>
</div>
<div class="step qr-section">
<div class="step-header">
<div class="step-number">2</div>
<h2 class="step-title">Scan QR Code</h2>
</div>
<p class="step-description">Use your phone's camera or Expo Go</p>
<div class="qr-code" id="qr-code"></div>
<a href="exps://EXPS_URL_PLACEHOLDER" class="open-button"
>Open in Expo Go</a
>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/qr-code-styling@1.6.0/lib/qr-code-styling.js"></script>
<script>
(function () {
const ua = navigator.userAgent;
const loadingEl = document.getElementById("loading");
const contentEl = document.getElementById("content");
const isAndroid = /Android/i.test(ua);
const isIOS =
/iPhone|iPad|iPod/i.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const deepLink = "exps://EXPS_URL_PLACEHOLDER";
// Adjust store buttons based on platform
const appStoreBtn = document.getElementById("app-store-btn");
const playStoreBtn = document.getElementById("play-store-btn");
const storeButtonsContainer = document.getElementById("store-buttons");
if (isIOS) {
playStoreBtn.className = "store-link";
storeButtonsContainer.appendChild(playStoreBtn);
} else if (isAndroid) {
appStoreBtn.className = "store-link";
storeButtonsContainer.insertBefore(playStoreBtn, appStoreBtn);
}
const qrCode = new QRCodeStyling({
width: 400,
height: 400,
data: deepLink,
dotsOptions: {
color: "#333333",
type: "rounded",
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "extra-rounded",
},
cornersDotOptions: {
type: "dot",
},
qrOptions: {
errorCorrectionLevel: "H",
},
});
qrCode.append(document.getElementById("qr-code"));
if (isAndroid || isIOS) {
loadingEl.style.display = "block";
contentEl.style.display = "none";
window.location.href = deepLink;
setTimeout(function () {
loadingEl.style.display = "none";
contentEl.style.display = "block";
}, 500);
}
})();
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"baseUrl": ".",
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"references": [
{
"path": "../../lib/api-client-react"
}
]
}
+6486 -8
View File
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -16,12 +16,49 @@ pnpm workspace monorepo using TypeScript. Each package manages its own dependenc
- **API codegen**: Orval (from OpenAPI spec) - **API codegen**: Orval (from OpenAPI spec)
- **Build**: esbuild (CJS bundle) - **Build**: esbuild (CJS bundle)
## Artifacts
### PostizMobile (`artifacts/postiz-mobile`)
Expo (React Native) mobile client for a self-hosted Postiz instance.
- **Preview path**: `/`
- **Theme**: Dark-only (`userInterfaceStyle: dark`)
- **Auth**: API key stored in `expo-secure-store`, passed as `Authorization` header
#### Screens / Tabs
1. **Calendar** (`app/(tabs)/index.tsx`) — Monthly calendar with post dots, tap day to see posts
2. **Posts** (`app/(tabs)/posts.tsx`) — Filterable list of posts with status badges, swipe to delete
3. **Compose** (`app/(tabs)/compose.tsx`) — Text editor, channel picker, date/time picker, image upload
4. **Settings** (`app/(tabs)/settings.tsx`) — API key + base URL, validation, SecureStore persistence
#### Key Files
- `context/PostizContext.tsx` — Axios client wired with API key/base URL; loaded from SecureStore on boot
- `components/PostCard.tsx` — Swipeable post card with delete action
- `components/StatusBadge.tsx` — QUEUE / PUBLISHED / ERROR / DRAFT badges
- `components/ChannelChip.tsx` — Integration channel selector chip
#### External API
- Base URL: `https://postiz.gyozamancave.fr/public/v1` (configurable)
- `GET /integrations` — List channels
- `GET /posts?startDate&endDate` — List posts
- `POST /posts` — Create/schedule post
- `DELETE /posts/:id` — Delete post
- `POST /upload` — Upload media
#### Packages Added
- `axios` — HTTP client
- `expo-secure-store` — Secure API key storage
- `react-native-calendars` — Calendar UI
- `@react-native-community/datetimepicker` — Date/time picker for compose
### API Server (`artifacts/api-server`)
Express 5 backend. Currently serves `/api/healthz`. Extend for server-side features.
## Key Commands ## Key Commands
- `pnpm run typecheck` — full typecheck across all packages - `pnpm run typecheck` — full typecheck across all packages
- `pnpm run build` — typecheck + build all packages - `pnpm run build` — typecheck + build all packages
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from OpenAPI spec - `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from OpenAPI spec
- `pnpm --filter @workspace/db run push` — push DB schema changes (dev only) - `pnpm --filter @workspace/db run push` — push DB schema changes (dev only)
- `pnpm --filter @workspace/api-server run dev` — run API server locally
See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details. See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details.