Add core functionality for mobile post scheduling app
Adds necessary dependencies including axios and react-native-calendars to pnpm-lock.yaml. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 7b0991ce-c7b8-4c82-9acc-fd3f9e762a01 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: dc1266fa-8375-43e1-aca0-9df31350f647 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/86064bd6-c937-4ca5-a5bf-bbef5749fb60/7b0991ce-c7b8-4c82-9acc-fd3f9e762a01/kWnlAIM Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
uploads = []
|
||||
outputs = []
|
||||
|
||||
[[generated]]
|
||||
id = "8iyg9o-DGcn6pChP77Vo1"
|
||||
uri = "file://artifacts/postiz-mobile/assets/images/icon.png"
|
||||
type = "image"
|
||||
title = "generated_image"
|
||||
@@ -18,3 +18,15 @@ expertMode = true
|
||||
[postMerge]
|
||||
path = "scripts/post-merge.sh"
|
||||
timeoutMs = 20000
|
||||
|
||||
[[ports]]
|
||||
localPort = 8080
|
||||
externalPort = 8080
|
||||
|
||||
[[ports]]
|
||||
localPort = 8081
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 20976
|
||||
externalPort = 3000
|
||||
|
||||
@@ -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 = "/"
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
module.exports = getDefaultConfig(__dirname);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+6486
-8
File diff suppressed because it is too large
Load Diff
@@ -16,12 +16,49 @@ pnpm workspace monorepo using TypeScript. Each package manages its own dependenc
|
||||
- **API codegen**: Orval (from OpenAPI spec)
|
||||
- **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
|
||||
|
||||
- `pnpm run typecheck` — full typecheck across 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/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.
|
||||
|
||||
Reference in New Issue
Block a user