1 Commits

Author SHA1 Message Date
billisdead 8b7a2eb644 feat: multi-workspace support + channels grouped by workspace and network
Release APK / build (push) Has been cancelled
- PostizContext: new PostizWorkspace type, multi-workspace storage
  (postiz_workspaces_v2), auto-migration from legacy single config,
  addWorkspace / updateWorkspace / removeWorkspace, clients map
- Settings: full rewrite with workspace card list (add / edit / delete)
- Compose: channels displayed in two levels — workspace section then
  network type (X/Twitter, Instagram, LinkedIn...) within each workspace;
  submit routes posts and image uploads per workspace
- MediaLibraryModal: workspace tabs when multiple workspaces configured,
  returned items carry their workspaceId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:50:20 +02:00
4 changed files with 743 additions and 542 deletions
+237 -64
View File
@@ -8,7 +8,7 @@ import * as ImageManipulator from "expo-image-manipulator";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { fetch as expoFetch } from "expo/fetch"; import { fetch as expoFetch } from "expo/fetch";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -24,8 +24,13 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip"; import { ChannelChip } from "@/components/ChannelChip";
import { MediaLibraryModal } from "@/components/MediaLibraryModal"; import { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import {
PostizIntegration,
PostizUploadResult,
PostizWorkspace,
usePostiz,
} from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
const DRAFT_STORAGE_KEY = "postiz_local_draft"; const DRAFT_STORAGE_KEY = "postiz_local_draft";
@@ -40,9 +45,33 @@ const NETWORK_CHAR_LIMITS: Record<string, number> = {
tiktok: 2200, tiktok: 2200,
}; };
// Integration enriched with its workspace info
type IntegrationWithWorkspace = PostizIntegration & {
workspaceId: string;
workspaceName: string;
workspace: PostizWorkspace;
};
type MediaItem = type MediaItem =
| { type: "local"; uri: string } | { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string }; | { type: "uploaded"; id: string; path: string; workspaceId: string };
// Maps a type string to a display label, used for grouping within a workspace
function networkLabel(intg: PostizIntegration): string {
const t = (intg.type ?? intg.internalType ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x-") || t === "x") return "X / Twitter";
if (t.includes("instagram")) return "Instagram";
if (t.includes("linkedin")) return "LinkedIn";
if (t.includes("facebook")) return "Facebook";
if (t.includes("tiktok")) return "TikTok";
if (t.includes("youtube")) return "YouTube";
if (t.includes("pinterest")) return "Pinterest";
if (t.includes("mastodon")) return "Mastodon";
if (t.includes("bluesky") || t.includes("bsky")) return "Bluesky";
if (t.includes("threads")) return "Threads";
if (t.includes("reddit")) return "Reddit";
return "Other";
}
function resolveMediaUrl(path: string, baseUrl: string): string { function resolveMediaUrl(path: string, baseUrl: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path; if (path.startsWith("http://") || path.startsWith("https://")) return path;
@@ -53,7 +82,7 @@ function resolveMediaUrl(path: string, baseUrl: string): string {
export default function ComposeScreen() { export default function ComposeScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } = const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -83,9 +112,11 @@ export default function ComposeScreen() {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
if (prefillImagePath && prefillImageId) { if (prefillImagePath && prefillImageId) {
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath) }]); // Prefilled image has unknown workspace; associate with first workspace
const wsId = workspaces[0]?.id ?? "";
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath), workspaceId: wsId }]);
} }
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]); }, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId, workspaces]);
useEffect(() => { useEffect(() => {
if (prefillContent) return; if (prefillContent) return;
@@ -98,21 +129,61 @@ export default function ComposeScreen() {
}); });
}, [prefillContent]); }, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } = // Fetch integrations from ALL workspaces in parallel
useQuery<PostizIntegration[]>({ const { data: allIntegrations, isLoading: loadingIntegrations } =
queryKey: ["integrations", !!client], useQuery<IntegrationWithWorkspace[]>({
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
queryFn: async () => { queryFn: async () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return []; if (!client) return [];
const res = await client.get("integrations"); const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? []; const list: PostizIntegration[] = Array.isArray(res.data)
? res.data
: (res.data?.integrations ?? []);
return list.map((i): IntegrationWithWorkspace => ({
...i,
workspaceId: ws.id,
workspaceName: ws.name,
workspace: ws,
}));
})
);
return results.flat();
}, },
enabled: !!client, enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
staleTime: 60000, staleTime: 60000,
}); });
const effectiveCharLimit = (() => { // Group: workspace → network label → integrations
if (selectedChannels.length === 0 || !integrations) return 3000; const grouped = useMemo(() => {
const selected = integrations.filter((i) => selectedChannels.includes(i.id)); if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of allIntegrations) {
if (!byWorkspace.has(intg.workspaceId)) byWorkspace.set(intg.workspaceId, []);
byWorkspace.get(intg.workspaceId)!.push(intg);
}
return workspaces
.filter((ws) => byWorkspace.has(ws.id))
.map((ws) => {
const intgs = byWorkspace.get(ws.id)!;
const byNetwork = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of intgs) {
const key = networkLabel(intg);
if (!byNetwork.has(key)) byNetwork.set(key, []);
byNetwork.get(key)!.push(intg);
}
return {
workspace: ws,
networks: Array.from(byNetwork.entries()).map(([label, channels]) => ({ label, channels })),
};
});
}, [allIntegrations, workspaces]);
const effectiveCharLimit = useMemo(() => {
if (selectedChannels.length === 0 || !allIntegrations) return 3000;
const selected = allIntegrations.filter((i) => selectedChannels.includes(i.id));
const limits = selected.map((i) => { const limits = selected.map((i) => {
const t = (i.type ?? i.internalType ?? "").toLowerCase(); const t = (i.type ?? i.internalType ?? "").toLowerCase();
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) { for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
@@ -121,7 +192,7 @@ export default function ComposeScreen() {
return 3000; return 3000;
}); });
return Math.min(...limits); return Math.min(...limits);
})(); }, [selectedChannels, allIntegrations]);
const saveDraft = async () => { const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels }; const draft = { content, integrationIds: selectedChannels };
@@ -193,44 +264,39 @@ export default function ComposeScreen() {
setMediaItems((prev) => prev.filter((_, i) => i !== index)); setMediaItems((prev) => prev.filter((_, i) => i !== index));
}; };
const buildMediaPayload = async (): Promise<Array<{ id: string; path: string }>> => { // Upload local images to a specific workspace, returns { id, path }[]
setUploading(true); const uploadLocalToWorkspace = async (
try { localUris: string[],
ws: PostizWorkspace
): Promise<Array<{ id: string; path: string }>> => {
const result: Array<{ id: string; path: string }> = []; const result: Array<{ id: string; path: string }> = [];
for (const item of mediaItems) { for (const uri of localUris) {
if (item.type === "uploaded") {
result.push({ id: item.id, path: item.path });
continue;
}
const formData = new FormData(); const formData = new FormData();
if (Platform.OS === "web") { if (Platform.OS === "web") {
const response = await expoFetch(item.uri); const response = await expoFetch(uri);
const blob = await response.blob(); const blob = await response.blob();
formData.append("file", blob, "upload.jpg"); formData.append("file", blob, "upload.jpg");
} else { } else {
formData.append("file", { formData.append("file", {
uri: item.uri, uri,
name: "upload.jpg", name: "upload.jpg",
type: "image/jpeg", type: "image/jpeg",
} as unknown as Blob); } as unknown as Blob);
} }
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST", method: "POST",
headers: { Authorization: apiKey }, headers: { Authorization: ws.apiKey },
body: formData, body: formData,
}); });
if (!uploadRes.ok) { if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText); const raw = await uploadRes.text().catch(() => uploadRes.statusText);
throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); throw new Error(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
} }
const uploaded = (await uploadRes.json()) as PostizUploadResult; const uploaded = (await uploadRes.json()) as PostizUploadResult;
result.push({ id: uploaded.id, path: uploaded.path }); result.push({ id: uploaded.id, path: uploaded.path });
} }
return result; return result;
} finally {
setUploading(false);
}
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -245,25 +311,55 @@ export default function ComposeScreen() {
} }
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true); setSubmitting(true);
try { try {
const media = mediaItems.length > 0 ? await buildMediaPayload() : []; // Group selected channels by workspace
const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
for (const channelId of selectedChannels) {
const intg = allIntegrations?.find((i) => i.id === channelId);
if (!intg) continue;
if (!byWorkspace.has(intg.workspaceId)) {
byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
}
byWorkspace.get(intg.workspaceId)!.channelIds.push(channelId);
}
const localUris = mediaItems.filter((m): m is MediaItem & { type: "local" } => m.type === "local").map((m) => m.uri);
const hasLocalImages = localUris.length > 0;
if (hasLocalImages) setUploading(true);
await Promise.all(
Array.from(byWorkspace.values()).map(async ({ ws, channelIds }) => {
// Already-uploaded media belonging to this workspace
const uploadedForWs = mediaItems
.filter((m): m is MediaItem & { type: "uploaded" } => m.type === "uploaded" && m.workspaceId === ws.id)
.map(({ id, path }) => ({ id, path }));
// Upload local images to this workspace
const localUploaded = hasLocalImages
? await uploadLocalToWorkspace(localUris, ws)
: [];
const media = [...uploadedForWs, ...localUploaded];
const payload = { const payload = {
type: postNow ? "now" : "schedule", type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false, shortLink: false,
tags: [] as string[], tags: [] as string[],
posts: selectedChannels.map((integrationId) => ({ posts: channelIds.map((integrationId) => ({
integration: { id: integrationId }, integration: { id: integrationId },
value: [{ content: content.trim(), image: media }], value: [{ content: content.trim(), image: media }],
})), })),
}; };
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log("[compose] POST", `${baseUrl}/posts`, body); console.log("[compose] POST", `${ws.baseUrl}/posts`, body);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, { const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
method: "POST", method: "POST",
headers: { Authorization: apiKey, "Content-Type": "application/json" }, headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
body, body,
}); });
@@ -271,14 +367,15 @@ export default function ComposeScreen() {
let detail = ""; let detail = "";
try { try {
const raw = await res.text(); const raw = await res.text();
console.log("[compose] error body:", raw); console.log(`[compose][${ws.name}] error body:`, raw);
detail = raw.slice(0, 500); detail = raw.slice(0, 500);
} catch { } catch { detail = res.statusText; }
detail = res.statusText; throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
}
throw new Error(`HTTP ${res.status}: ${detail}`);
} }
})
);
if (hasLocalImages) setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY); await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
@@ -289,6 +386,7 @@ export default function ComposeScreen() {
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e: unknown) { } catch (e: unknown) {
setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
const msg = e instanceof Error ? e.message : "Could not submit post."; const msg = e instanceof Error ? e.message : "Could not submit post.";
Alert.alert("Failed", msg); Alert.alert("Failed", msg);
@@ -317,7 +415,7 @@ export default function ComposeScreen() {
<View style={[styles.centered, { backgroundColor: colors.background }]}> <View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="lock" size={32} color={colors.mutedForeground} /> <Feather name="lock" size={32} color={colors.mutedForeground} />
<Text style={[styles.sectionTitle, { color: colors.foreground }]}>Not Configured</Text> <Text style={[styles.sectionTitle, { color: colors.foreground }]}>Not Configured</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>Add your API key in Settings</Text> <Text style={[styles.hint, { color: colors.mutedForeground }]}>Add a workspace in Settings</Text>
</View> </View>
); );
} }
@@ -395,7 +493,7 @@ export default function ComposeScreen() {
const uri = const uri =
item.type === "local" item.type === "local"
? item.uri ? item.uri
: resolveMediaUrl(item.path, baseUrl); : resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? "");
return ( return (
<View key={idx} style={styles.imageThumbWrap}> <View key={idx} style={styles.imageThumbWrap}>
<Image <Image
@@ -443,21 +541,48 @@ export default function ComposeScreen() {
</View> </View>
)} )}
{/* Channels grouped by workspace then network type */}
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text> <Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
{loadingIntegrations ? ( {loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} /> <ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? ( ) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance. No channels found. Add integrations in your Postiz instance.
</Text> </Text>
) : ( ) : (
<ScrollView <View style={styles.channelGroups}>
horizontal {grouped.map(({ workspace, networks }, wsIdx) => (
showsHorizontalScrollIndicator={false} <View
contentContainerStyle={styles.channelList} key={workspace.id}
style={[
styles.workspaceSection,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
wsIdx > 0 && { marginTop: 8 },
]}
> >
{(integrations ?? []).map((intg) => ( {/* Workspace header */}
<View style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}>
<Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary }]}>
{workspace.name}
</Text>
</View>
{/* Network groups */}
<View style={styles.networkGroups}>
{networks.map(({ label, channels }) => (
<View key={label} style={styles.networkGroup}>
{networks.length > 1 && (
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
)}
<View style={styles.chipRow}>
{channels.map((intg) => (
<ChannelChip <ChannelChip
key={intg.id} key={intg.id}
integration={intg} integration={intg}
@@ -465,7 +590,13 @@ export default function ComposeScreen() {
onToggle={() => toggleChannel(intg.id)} onToggle={() => toggleChannel(intg.id)}
/> />
))} ))}
</ScrollView> </View>
</View>
))}
</View>
</View>
))}
</View>
)} )}
<View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}>
@@ -577,13 +708,20 @@ export default function ComposeScreen() {
<MediaLibraryModal <MediaLibraryModal
visible={showMediaLibrary} visible={showMediaLibrary}
baseUrl={baseUrl} workspaces={workspaces}
apiKey={apiKey}
maxSelect={MAX_IMAGES - mediaItems.length} maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)} onClose={() => setShowMediaLibrary(false)}
onSelect={(items) => { onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) => setMediaItems((prev) =>
[...prev, ...items.map((i): MediaItem => ({ type: "uploaded", id: i.id, path: i.path }))].slice(0, MAX_IMAGES) [
...prev,
...items.map((i): MediaItem => ({
type: "uploaded",
id: i.id,
path: i.path,
workspaceId: i.workspaceId,
})),
].slice(0, MAX_IMAGES)
); );
setShowMediaLibrary(false); setShowMediaLibrary(false);
}} }}
@@ -600,29 +738,64 @@ const styles = StyleSheet.create({
charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 }, charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 },
charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" }, charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" },
charCount: { fontSize: 11, fontFamily: "Inter_400Regular" }, charCount: { fontSize: 11, fontFamily: "Inter_400Regular" },
draftBanner: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1 }, draftBanner: {
flexDirection: "row", alignItems: "center", gap: 8,
paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
},
draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" }, draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" }, draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
draftBtn: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1 }, draftBtn: {
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
},
draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" }, draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
imageRow: { gap: 10, paddingRight: 4 }, imageRow: { gap: 10, paddingRight: 4 },
imageThumbWrap: { position: "relative" }, imageThumbWrap: { position: "relative" },
imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 }, imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
removeImg: { position: "absolute", top: 4, right: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center" }, removeImg: {
uploadedBadge: { position: "absolute", bottom: 4, left: 4, width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center" }, position: "absolute", top: 4, right: 4,
width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center",
},
uploadedBadge: {
position: "absolute", bottom: 4, left: 4,
width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center",
},
mediaBtnsRow: { flexDirection: "row", gap: 8 }, mediaBtnsRow: { flexDirection: "row", gap: 8 },
mediaBtn: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1 }, mediaBtn: {
flexDirection: "row", alignItems: "center", gap: 8,
paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
},
mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" }, mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 }, sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" }, sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" }, hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
channelList: { flexDirection: "row", gap: 8, flexWrap: "wrap" }, channelGroups: { gap: 0 },
scheduleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1 }, workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
workspaceHeader: {
flexDirection: "row", alignItems: "center", gap: 6,
paddingHorizontal: 12, paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
networkGroups: { padding: 10, gap: 10 },
networkGroup: { gap: 4 },
networkLabel: { fontSize: 10, fontFamily: "Inter_500Medium", letterSpacing: 0.4, marginLeft: 2 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
scheduleRow: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
},
scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 }, scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" }, scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
dateTimeRow: { flexDirection: "row", gap: 10 }, dateTimeRow: { flexDirection: "row", gap: 10 },
dateBtn: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1 }, dateBtn: {
flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
},
dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" }, dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
submitBtn: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4 }, submitBtn: {
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
},
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" }, submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
}); });
+211 -220
View File
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import axios from "axios"; import axios from "axios";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -15,49 +15,73 @@ import {
} from "react-native"; } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz, DEFAULT_BASE_URL } from "@/context/PostizContext"; import { PostizWorkspace, DEFAULT_BASE_URL, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError"; import { extractError } from "@/lib/extractError";
type FormState = {
id?: string;
name: string;
url: string;
key: string;
};
const EMPTY_FORM: FormState = { name: "", url: DEFAULT_BASE_URL, key: "" };
export default function SettingsScreen() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz(); const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey); const [form, setForm] = useState<FormState | null>(null);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle"); const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
const [errorDetail, setErrorDetail] = useState<string>(""); const [errorDetail, setErrorDetail] = useState("");
useEffect(() => { const openAdd = () => {
setInputKey(apiKey); setForm(EMPTY_FORM);
setInputUrl(baseUrl || DEFAULT_BASE_URL); setShowKey(false);
}, [apiKey, baseUrl]); resetValidation();
};
const openEdit = (ws: PostizWorkspace) => {
setForm({ id: ws.id, name: ws.name, url: ws.baseUrl, key: ws.apiKey });
setShowKey(false);
resetValidation();
};
const closeForm = () => {
setForm(null);
resetValidation();
};
const resetValidation = () => {
setValidationStatus("idle");
setErrorDetail("");
};
const patchForm = (patch: Partial<FormState>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev));
resetValidation();
};
const handleValidate = async () => { const handleValidate = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form?.key.trim() || !form?.url.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL."); Alert.alert("Missing fields", "Please enter both API key and base URL.");
return; return;
} }
setValidating(true); setValidating(true);
setValidationStatus("idle"); resetValidation();
setErrorDetail(""); const cleanUrl = form.url.trim().replace(/\/$/, "");
const cleanUrl = inputUrl.trim().replace(/\/$/, ""); const variants = [form.key.trim(), `Bearer ${form.key.trim()}`];
let lastError = "";
const authVariants = [ for (const auth of variants) {
inputKey.trim(),
`Bearer ${inputKey.trim()}`,
];
let lastError: string = "";
for (const authHeader of authVariants) {
try { try {
await axios.get(`${cleanUrl}/integrations`, { await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader }, headers: { Authorization: auth },
timeout: 10000, timeout: 10000,
maxRedirects: 0, maxRedirects: 0,
}); });
@@ -67,21 +91,17 @@ export default function SettingsScreen() {
return; return;
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const status = err.response?.status; const s = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) { if (s === 307 || s === 301 || s === 302 || s === 308) {
const location = err.response?.headers?.location ?? "unknown"; const loc = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`; lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`;
continue;
}
if (status === 401 || status === 403) {
lastError = `HTTP ${status}: Invalid or expired API key.`;
continue; continue;
} }
if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; }
} }
lastError = extractError(err); lastError = extractError(err);
} }
} }
setErrorDetail(lastError); setErrorDetail(lastError);
setValidationStatus("error"); setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
@@ -89,37 +109,38 @@ export default function SettingsScreen() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form) return;
Alert.alert("Missing fields", "Please enter both API key and base URL."); if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; }
return; if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; }
}
setSaving(true); setSaving(true);
try { try {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, "")); const ws = { name: form.name.trim(), apiKey: form.key.trim(), baseUrl: form.url.trim().replace(/\/$/, "") };
if (form.id) {
await updateWorkspace({ ...ws, id: form.id });
} else {
await addWorkspace(ws);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); closeForm();
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`); Alert.alert("Error", `Failed to save.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleClear = () => { const handleDelete = (ws: PostizWorkspace) => {
Alert.alert( Alert.alert(
"Disconnect", "Remove workspace",
"Remove your API key and disconnect from Postiz?", `Remove "${ws.name}"? Channels from this workspace will no longer be available.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Disconnect", text: "Remove",
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await clearSettings(); if (form?.id === ws.id) closeForm();
setInputKey(""); await removeWorkspace(ws.id);
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
setErrorDetail("");
}, },
}, },
] ]
@@ -140,34 +161,96 @@ export default function SettingsScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{!isConfigured && ( {/* Status banner */}
{!isConfigured ? (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}> <View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} /> <Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { color: colors.primary }]}> <Text style={[styles.bannerText, { color: colors.primary }]}>
Connect to your Postiz instance to get started Add a workspace to get started
</Text> </Text>
</View> </View>
)} ) : (
{isConfigured && (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}> <View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} /> <Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}> <Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz {workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured
</Text> </Text>
</View> </View>
)} )}
<View style={styles.section}> {/* Workspace cards */}
{workspaces.map((ws) => (
<View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.wsCardHeader}>
<View style={styles.wsCardLeft}>
<View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
<Feather name="briefcase" size={14} color={colors.primary} />
</View>
<View>
<Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
<Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
{ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
</Text>
</View>
</View>
<View style={styles.wsCardActions}>
<TouchableOpacity onPress={() => openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="edit-2" size={15} color={colors.mutedForeground} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="trash-2" size={15} color={colors.destructive} />
</TouchableOpacity>
</View>
</View>
</View>
))}
{/* Add workspace button */}
{!form && (
<TouchableOpacity
onPress={openAdd}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity>
)}
{/* Add / Edit form */}
{form && (
<View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.formTitle, { color: colors.foreground }]}>
{form.id ? "Edit workspace" : "Add workspace"}
</Text>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>NAME</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="briefcase" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="My Client"
placeholderTextColor={colors.mutedForeground}
value={form.name}
onChangeText={(t) => patchForm({ name: t })}
autoCorrect={false}
/>
</View>
</View>
{/* Base URL */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1" placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputUrl} value={form.url}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
@@ -175,31 +258,30 @@ export default function SettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}> {/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key" placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputKey} value={form.key}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey} secureTextEntry={!showKey}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}> <TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} /> <Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{validationStatus === "ok" && ( {validationStatus === "ok" && (
<View style={styles.validationRow}> <View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} /> <Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}> <Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
Connection successful
</Text>
</View> </View>
)} )}
@@ -207,39 +289,42 @@ export default function SettingsScreen() {
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}> <View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}> <View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} /> <Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}> <Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
Could not connect
</Text>
</View> </View>
{!!errorDetail && ( {!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled> <ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable> <Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
{errorDetail}
</Text>
</ScrollView> </ScrollView>
)} )}
</View> </View>
)} )}
</View> </View>
{/* Form actions */}
<TouchableOpacity <TouchableOpacity
onPress={handleValidate} onPress={handleValidate}
activeOpacity={0.8} activeOpacity={0.8}
disabled={validating} disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
> >
{validating ? ( {validating ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
) : ( ) : (
<> <>
<Feather name="wifi" size={15} color={colors.primary} /> <Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}> <Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
Test Connection
</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
<View style={styles.formBtnsRow}>
<TouchableOpacity
onPress={closeForm}
activeOpacity={0.8}
style={[styles.cancelBtn, { borderColor: colors.border }]}
>
<Text style={[styles.cancelText, { color: colors.mutedForeground }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={handleSave} onPress={handleSave}
activeOpacity={0.85} activeOpacity={0.85}
@@ -252,169 +337,75 @@ export default function SettingsScreen() {
<> <>
<Feather name="save" size={15} color={colors.primaryForeground} /> <Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}> <Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings {form.id ? "Update" : "Save"}
</Text> </Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{isConfigured && ( </View>
<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 }]}> <Text style={[styles.footerText, { color: colors.mutedForeground }]}>
Your API key is stored securely on this device and never transmitted to third parties. API keys are stored securely on this device and never transmitted to third parties.
</Text> </Text>
</View>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 20, gap: 14 },
paddingHorizontal: 20,
gap: 16,
},
banner: { banner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 10,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
}, },
bannerText: { fontSize: 13, fontFamily: "Inter_500Medium", flex: 1 },
connectedBadge: { connectedBadge: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 6,
alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, alignSelf: "flex-start",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
connectedText: { connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
fontSize: 12, wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 },
}, wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
section: { wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" },
gap: 8, wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
}, wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 },
label: { wsCardActions: { flexDirection: "row", gap: 4 },
fontSize: 11, iconBtn: { padding: 8 },
fontFamily: "Inter_600SemiBold", addBtn: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", justifyContent: "center",
marginLeft: 2, gap: 8, paddingVertical: 13, borderRadius: 14, borderWidth: 1, borderStyle: "dashed",
}, },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formCard: { borderRadius: 14, borderWidth: 1, padding: 16, gap: 14 },
formTitle: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
fieldGroup: { gap: 6 },
label: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginLeft: 2 },
inputWrap: { inputWrap: {
flexDirection: "row", flexDirection: "row", alignItems: "center",
alignItems: "center", borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10,
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",
},
errorBox: {
borderRadius: 10,
borderWidth: 1,
padding: 12,
gap: 6,
},
errorHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
errorTitle: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
errorScroll: {
maxHeight: 80,
},
errorDetail: {
fontSize: 11,
fontFamily: "Inter_400Regular",
lineHeight: 16,
}, },
input: { flex: 1, fontSize: 14, fontFamily: "Inter_400Regular" },
validRow: { flexDirection: "row", alignItems: "center", gap: 6, marginLeft: 2 },
validText: { fontSize: 12, fontFamily: "Inter_400Regular" },
errorBox: { borderRadius: 10, borderWidth: 1, padding: 12, gap: 6 },
errorHeader: { flexDirection: "row", alignItems: "center", gap: 6 },
errorTitle: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
errorScroll: { maxHeight: 80 },
errorDetail: { fontSize: 11, fontFamily: "Inter_400Regular", lineHeight: 16 },
validateBtn: { validateBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1,
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
}, },
validateText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formBtnsRow: { flexDirection: "row", gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: "center" },
cancelText: { fontSize: 14, fontFamily: "Inter_500Medium" },
saveBtn: { saveBtn: {
flexDirection: "row", flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 12, borderRadius: 10,
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,
}, },
saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 },
}); });
@@ -5,15 +5,24 @@ import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
Modal, Modal,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizWorkspace } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
interface MediaItem { export interface LibraryMediaItem {
id: string;
path: string;
workspaceId: string;
createdAt?: string;
}
interface RawMediaItem {
id: string; id: string;
path: string; path: string;
createdAt?: string; createdAt?: string;
@@ -21,11 +30,11 @@ interface MediaItem {
interface Props { interface Props {
visible: boolean; visible: boolean;
baseUrl: string; workspaces: PostizWorkspace[];
apiKey: string; defaultWorkspaceId?: string;
maxSelect: number; maxSelect: number;
onClose: () => void; onClose: () => void;
onSelect: (items: MediaItem[]) => void; onSelect: (items: LibraryMediaItem[]) => void;
} }
function resolveUrl(path: string, baseUrl: string): string { function resolveUrl(path: string, baseUrl: string): string {
@@ -34,26 +43,37 @@ function resolveUrl(path: string, baseUrl: string): string {
return `${origin}/${path.replace(/^\//, "")}`; return `${origin}/${path.replace(/^\//, "")}`;
} }
export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose, onSelect }: Props) { export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect }: Props) {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [items, setItems] = useState<MediaItem[]>([]); const [activeId, setActiveId] = useState<string>("");
const [items, setItems] = useState<RawMediaItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const activeWorkspace = workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null;
useEffect(() => {
if (visible) {
const initial = defaultWorkspaceId ?? workspaces[0]?.id ?? "";
setActiveId(initial);
setSelected(new Set());
}
}, [visible, defaultWorkspaceId, workspaces]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!baseUrl || !apiKey) return; if (!activeWorkspace) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/media`, { const res = await globalThis.fetch(`${activeWorkspace.baseUrl}/media`, {
headers: { Authorization: apiKey }, headers: { Authorization: activeWorkspace.apiKey },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
const list: MediaItem[] = Array.isArray(data) const list: RawMediaItem[] = Array.isArray(data)
? data ? data
: (data?.media ?? data?.items ?? data?.files ?? []); : (data?.media ?? data?.items ?? data?.files ?? []);
setItems(list); setItems(list);
@@ -62,35 +82,36 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [baseUrl, apiKey]); }, [activeWorkspace]);
useEffect(() => { useEffect(() => {
if (visible) { if (visible && activeWorkspace) {
setSelected(new Set()); setSelected(new Set());
load(); load();
} }
}, [visible, load]); }, [visible, activeWorkspace, load]);
const toggle = (id: string) => { const toggle = (id: string) => {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) { if (next.has(id)) { next.delete(id); }
next.delete(id); else if (next.size < maxSelect) { next.add(id); }
} else if (next.size < maxSelect) {
next.add(id);
}
return next; return next;
}); });
}; };
const handleConfirm = () => { const handleConfirm = () => {
const chosen = items.filter((i) => selected.has(i.id)); if (!activeWorkspace) return;
const chosen = items
.filter((i) => selected.has(i.id))
.map((i): LibraryMediaItem => ({ ...i, workspaceId: activeWorkspace.id }));
onSelect(chosen); onSelect(chosen);
}; };
return ( return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}> <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}> <View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: colors.border }]}> <View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onClose} activeOpacity={0.7} style={styles.closeBtn}> <TouchableOpacity onPress={onClose} activeOpacity={0.7} style={styles.closeBtn}>
<Feather name="x" size={20} color={colors.foreground} /> <Feather name="x" size={20} color={colors.foreground} />
@@ -100,10 +121,7 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
onPress={handleConfirm} onPress={handleConfirm}
disabled={selected.size === 0} disabled={selected.size === 0}
activeOpacity={0.8} activeOpacity={0.8}
style={[ style={[styles.addBtn, { backgroundColor: selected.size > 0 ? colors.primary : colors.muted }]}
styles.addBtn,
{ backgroundColor: selected.size > 0 ? colors.primary : colors.muted },
]}
> >
<Text style={[styles.addBtnText, { color: colors.primaryForeground }]}> <Text style={[styles.addBtnText, { color: colors.primaryForeground }]}>
{selected.size > 0 ? `Add ${selected.size}` : "Add"} {selected.size > 0 ? `Add ${selected.size}` : "Add"}
@@ -111,6 +129,36 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Workspace tabs (only shown when >1 workspace) */}
{workspaces.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabs, { borderBottomColor: colors.border }]}
contentContainerStyle={styles.tabsContent}
>
{workspaces.map((ws) => {
const active = ws.id === activeId;
return (
<TouchableOpacity
key={ws.id}
onPress={() => setActiveId(ws.id)}
activeOpacity={0.7}
style={[
styles.tab,
active && { borderBottomColor: colors.primary, borderBottomWidth: 2 },
]}
>
<Text style={[styles.tabText, { color: active ? colors.primary : colors.mutedForeground }]}>
{ws.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
{/* Content */}
{loading ? ( {loading ? (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" /> <ActivityIndicator color={colors.primary} size="large" />
@@ -119,11 +167,7 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
<View style={styles.centered}> <View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} /> <Feather name="alert-circle" size={28} color={colors.error} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>{error}</Text> <Text style={[styles.errorText, { color: colors.mutedForeground }]}>{error}</Text>
<TouchableOpacity <TouchableOpacity onPress={load} style={[styles.retryBtn, { backgroundColor: colors.primary }]} activeOpacity={0.8}>
onPress={load}
style={[styles.retryBtn, { backgroundColor: colors.primary }]}
activeOpacity={0.8}
>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>Retry</Text> <Text style={[styles.retryText, { color: colors.primaryForeground }]}>Retry</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -140,18 +184,10 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]} contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]}
renderItem={({ item }) => { renderItem={({ item }) => {
const isSelected = selected.has(item.id); const isSelected = selected.has(item.id);
const uri = resolveUrl(item.path, baseUrl); const uri = resolveUrl(item.path, activeWorkspace?.baseUrl ?? "");
return ( return (
<TouchableOpacity <TouchableOpacity onPress={() => toggle(item.id)} activeOpacity={0.8} style={styles.cell}>
onPress={() => toggle(item.id)} <Image source={{ uri }} style={styles.cellImage} contentFit="cover" />
activeOpacity={0.8}
style={styles.cell}
>
<Image
source={{ uri }}
style={styles.cellImage}
contentFit="cover"
/>
{isSelected && ( {isSelected && (
<View style={[styles.selectedOverlay, { backgroundColor: colors.primary + "99" }]}> <View style={[styles.selectedOverlay, { backgroundColor: colors.primary + "99" }]}>
<View style={[styles.checkCircle, { backgroundColor: colors.primary }]}> <View style={[styles.checkCircle, { backgroundColor: colors.primary }]}>
@@ -174,17 +210,18 @@ const CELL = 120;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1 }, root: { flex: 1 },
header: { header: {
flexDirection: "row", flexDirection: "row", alignItems: "center",
alignItems: "center", paddingHorizontal: 16, paddingVertical: 14,
paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth, gap: 12,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 12,
}, },
closeBtn: { padding: 4 }, closeBtn: { padding: 4 },
title: { flex: 1, fontSize: 17, fontFamily: "Inter_600SemiBold" }, title: { flex: 1, fontSize: 17, fontFamily: "Inter_600SemiBold" },
addBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 }, addBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
tabs: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 },
tabsContent: { paddingHorizontal: 12, gap: 4 },
tab: { paddingHorizontal: 12, paddingVertical: 12 },
tabText: { fontSize: 13, fontFamily: "Inter_500Medium" },
centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 }, centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
errorText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center", paddingHorizontal: 32 }, errorText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center", paddingHorizontal: 32 },
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular" }, emptyText: { fontSize: 14, fontFamily: "Inter_400Regular" },
@@ -194,16 +231,8 @@ const styles = StyleSheet.create({
cell: { width: CELL, height: CELL, margin: 2 }, cell: { width: CELL, height: CELL, margin: 2 },
cellImage: { width: CELL, height: CELL, borderRadius: 4 }, cellImage: { width: CELL, height: CELL, borderRadius: 4 },
selectedOverlay: { selectedOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject, borderRadius: 4,
borderRadius: 4, alignItems: "center", justifyContent: "center",
alignItems: "center",
justifyContent: "center",
},
checkCircle: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
}, },
checkCircle: { width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center" },
}); });
@@ -5,14 +5,22 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const WORKSPACES_KEY = "postiz_workspaces_v2";
const BASE_URL_STORAGE = "postiz_base_url"; const LEGACY_API_KEY = "postiz_api_key";
const LEGACY_BASE_URL = "postiz_base_url";
export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1"; export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizWorkspace {
id: string;
name: string;
apiKey: string;
baseUrl: string;
}
export interface PostizIntegration { export interface PostizIntegration {
id: string; id: string;
name: string; name: string;
@@ -45,129 +53,129 @@ export interface PostizUploadResult {
} }
interface PostizContextValue { interface PostizContextValue {
apiKey: string; workspaces: PostizWorkspace[];
baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
unauthorized: boolean; clients: Record<string, AxiosInstance>;
clearUnauthorized: () => void; addWorkspace: (ws: Omit<PostizWorkspace, "id">) => Promise<void>;
updateWorkspace: (ws: PostizWorkspace) => Promise<void>;
removeWorkspace: (id: string) => Promise<void>;
// backward compat for posts.tsx (first workspace)
client: AxiosInstance | null; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; apiKey: string;
clearSettings: () => Promise<void>; baseUrl: string;
} }
const PostizContext = createContext<PostizContextValue>({ const PostizContext = createContext<PostizContextValue>({
apiKey: "", workspaces: [],
baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
unauthorized: false, clients: {},
clearUnauthorized: () => {}, addWorkspace: async () => {},
updateWorkspace: async () => {},
removeWorkspace: async () => {},
client: null, client: null,
saveSettings: async () => {}, apiKey: "",
clearSettings: async () => {}, baseUrl: DEFAULT_BASE_URL,
}); });
function createClient( function makeClient(ws: PostizWorkspace): AxiosInstance {
apiKey: string, const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/";
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL,
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
timeout: 15000, timeout: 15000,
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || "")); console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL ?? "") + (config.url ?? ""));
return config; return config;
}); });
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance; return instance;
} }
function buildClients(list: PostizWorkspace[]): Record<string, AxiosInstance> {
return Object.fromEntries(list.map((ws) => [ws.id, makeClient(ws)]));
}
export function PostizProvider({ children }: { children: React.ReactNode }) { export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState(""); const [workspaces, setWorkspaces] = useState<PostizWorkspace[]>([]);
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [clients, setClients] = useState<Record<string, AxiosInstance>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE); const stored = await SecureStore.getItemAsync(WORKSPACES_KEY);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE); if (stored) {
if (storedKey) { const list: PostizWorkspace[] = JSON.parse(stored);
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); setWorkspaces(list);
setApiKey(storedKey); setClients(buildClients(list));
setBaseUrl(url); } else {
setClient(() => createClient(storedKey, url, handleUnauthorized)); // Migrate legacy single-workspace config
const legacyKey = await SecureStore.getItemAsync(LEGACY_API_KEY);
const legacyUrl = await SecureStore.getItemAsync(LEGACY_BASE_URL);
if (legacyKey) {
const migrated: PostizWorkspace = {
id: Date.now().toString(),
name: "Default",
apiKey: legacyKey,
baseUrl: (legacyUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
};
const list = [migrated];
await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
setWorkspaces(list);
setClients(buildClients(list));
}
} }
} catch { } catch {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
}, [handleUnauthorized]); }, []);
const saveSettings = useCallback( const persist = useCallback(async (list: PostizWorkspace[]) => {
async (newApiKey: string, newBaseUrl: string) => { await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey); setWorkspaces(list);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); setClients(buildClients(list));
setApiKey(newApiKey); }, []);
setBaseUrl(newBaseUrl);
clearUnauthorized(); const addWorkspace = useCallback(
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized)); async (ws: Omit<PostizWorkspace, "id">) => {
await persist([...workspaces, { ...ws, id: Date.now().toString() }]);
}, },
[handleUnauthorized, clearUnauthorized] [workspaces, persist]
); );
const clearSettings = useCallback(async () => { const updateWorkspace = useCallback(
await SecureStore.deleteItemAsync(API_KEY_STORAGE); async (ws: PostizWorkspace) => {
await SecureStore.deleteItemAsync(BASE_URL_STORAGE); await persist(workspaces.map((w) => (w.id === ws.id ? ws : w)));
setApiKey(""); },
setBaseUrl(DEFAULT_BASE_URL); [workspaces, persist]
setClient(null); );
clearUnauthorized();
}, [clearUnauthorized]); const removeWorkspace = useCallback(
async (id: string) => {
await persist(workspaces.filter((w) => w.id !== id));
},
[workspaces, persist]
);
const primaryWorkspace = workspaces[0] ?? null;
return ( return (
<PostizContext.Provider <PostizContext.Provider
value={{ value={{
apiKey, workspaces,
baseUrl, isConfigured: workspaces.length > 0,
isConfigured: !!apiKey,
isLoading, isLoading,
unauthorized, clients,
clearUnauthorized, addWorkspace,
client, updateWorkspace,
saveSettings, removeWorkspace,
clearSettings, client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null,
apiKey: primaryWorkspace?.apiKey ?? "",
baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL,
}} }}
> >
{children} {children}