From 8b7a2eb644345995e2febebc2de6fd3b7636a032 Mon Sep 17 00:00:00 2001 From: Antoine Piron Date: Thu, 11 Jun 2026 14:50:20 +0200 Subject: [PATCH] feat: multi-workspace support + channels grouped by workspace and network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../postiz-mobile/app/(tabs)/compose.tsx | 399 ++++++++---- .../postiz-mobile/app/(tabs)/settings.tsx | 567 +++++++++--------- .../components/MediaLibraryModal.tsx | 141 +++-- .../postiz-mobile/context/PostizContext.tsx | 178 +++--- 4 files changed, 743 insertions(+), 542 deletions(-) diff --git a/artifacts/postiz-mobile/app/(tabs)/compose.tsx b/artifacts/postiz-mobile/app/(tabs)/compose.tsx index 86cf6d3..77138f2 100644 --- a/artifacts/postiz-mobile/app/(tabs)/compose.tsx +++ b/artifacts/postiz-mobile/app/(tabs)/compose.tsx @@ -8,7 +8,7 @@ import * as ImageManipulator from "expo-image-manipulator"; import * as ImagePicker from "expo-image-picker"; import { fetch as expoFetch } from "expo/fetch"; import { useLocalSearchParams } from "expo-router"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Alert, @@ -24,8 +24,13 @@ import { import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChannelChip } from "@/components/ChannelChip"; -import { MediaLibraryModal } from "@/components/MediaLibraryModal"; -import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; +import { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal"; +import { + PostizIntegration, + PostizUploadResult, + PostizWorkspace, + usePostiz, +} from "@/context/PostizContext"; import { useColors } from "@/hooks/useColors"; const DRAFT_STORAGE_KEY = "postiz_local_draft"; @@ -40,9 +45,33 @@ const NETWORK_CHAR_LIMITS: Record = { tiktok: 2200, }; +// Integration enriched with its workspace info +type IntegrationWithWorkspace = PostizIntegration & { + workspaceId: string; + workspaceName: string; + workspace: PostizWorkspace; +}; + type MediaItem = | { 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 { if (path.startsWith("http://") || path.startsWith("https://")) return path; @@ -53,7 +82,7 @@ function resolveMediaUrl(path: string, baseUrl: string): string { export default function ComposeScreen() { const colors = useColors(); const insets = useSafeAreaInsets(); - const { client, isConfigured, apiKey, baseUrl } = usePostiz(); + const { workspaces, clients, isConfigured } = usePostiz(); const queryClient = useQueryClient(); const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } = useLocalSearchParams<{ @@ -83,9 +112,11 @@ export default function ComposeScreen() { setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); } 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(() => { if (prefillContent) return; @@ -98,21 +129,61 @@ export default function ComposeScreen() { }); }, [prefillContent]); - const { data: integrations, isLoading: loadingIntegrations } = - useQuery({ - queryKey: ["integrations", !!client], + // Fetch integrations from ALL workspaces in parallel + const { data: allIntegrations, isLoading: loadingIntegrations } = + useQuery({ + queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")], queryFn: async () => { - if (!client) return []; - const res = await client.get("integrations"); - return Array.isArray(res.data) ? res.data : res.data?.integrations ?? []; + const results = await Promise.all( + workspaces.map(async (ws) => { + const client = clients[ws.id]; + if (!client) return []; + const res = await client.get("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, }); - const effectiveCharLimit = (() => { - if (selectedChannels.length === 0 || !integrations) return 3000; - const selected = integrations.filter((i) => selectedChannels.includes(i.id)); + // Group: workspace → network label → integrations + const grouped = useMemo(() => { + if (!allIntegrations) return []; + const byWorkspace = new Map(); + 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(); + 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 t = (i.type ?? i.internalType ?? "").toLowerCase(); for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) { @@ -121,7 +192,7 @@ export default function ComposeScreen() { return 3000; }); return Math.min(...limits); - })(); + }, [selectedChannels, allIntegrations]); const saveDraft = async () => { const draft = { content, integrationIds: selectedChannels }; @@ -193,44 +264,39 @@ export default function ComposeScreen() { setMediaItems((prev) => prev.filter((_, i) => i !== index)); }; - const buildMediaPayload = async (): Promise> => { - setUploading(true); - try { - const result: Array<{ id: string; path: string }> = []; - for (const item of mediaItems) { - if (item.type === "uploaded") { - result.push({ id: item.id, path: item.path }); - continue; - } - const formData = new FormData(); - if (Platform.OS === "web") { - const response = await expoFetch(item.uri); - const blob = await response.blob(); - formData.append("file", blob, "upload.jpg"); - } else { - formData.append("file", { - uri: item.uri, - name: "upload.jpg", - type: "image/jpeg", - } as unknown as Blob); - } - // eslint-disable-next-line no-undef - const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { - method: "POST", - headers: { Authorization: apiKey }, - body: formData, - }); - if (!uploadRes.ok) { - const raw = await uploadRes.text().catch(() => uploadRes.statusText); - throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); - } - const uploaded = (await uploadRes.json()) as PostizUploadResult; - result.push({ id: uploaded.id, path: uploaded.path }); + // Upload local images to a specific workspace, returns { id, path }[] + const uploadLocalToWorkspace = async ( + localUris: string[], + ws: PostizWorkspace + ): Promise> => { + const result: Array<{ id: string; path: string }> = []; + for (const uri of localUris) { + const formData = new FormData(); + if (Platform.OS === "web") { + const response = await expoFetch(uri); + const blob = await response.blob(); + formData.append("file", blob, "upload.jpg"); + } else { + formData.append("file", { + uri, + name: "upload.jpg", + type: "image/jpeg", + } as unknown as Blob); } - return result; - } finally { - setUploading(false); + // eslint-disable-next-line no-undef + const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, { + method: "POST", + headers: { Authorization: ws.apiKey }, + body: formData, + }); + if (!uploadRes.ok) { + const raw = await uploadRes.text().catch(() => uploadRes.statusText); + throw new Error(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); + } + const uploaded = (await uploadRes.json()) as PostizUploadResult; + result.push({ id: uploaded.id, path: uploaded.path }); } + return result; }; const handleSubmit = async () => { @@ -245,40 +311,71 @@ export default function ComposeScreen() { } Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setSubmitting(true); + try { - const media = mediaItems.length > 0 ? await buildMediaPayload() : []; - const payload = { - type: postNow ? "now" : "schedule", - date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), - shortLink: false, - tags: [] as string[], - posts: selectedChannels.map((integrationId) => ({ - integration: { id: integrationId }, - value: [{ content: content.trim(), image: media }], - })), - }; - const body = JSON.stringify(payload); - console.log("[compose] POST", `${baseUrl}/posts`, body); - - // eslint-disable-next-line no-undef - const res = await globalThis.fetch(`${baseUrl}/posts`, { - method: "POST", - headers: { Authorization: apiKey, "Content-Type": "application/json" }, - body, - }); - - if (!res.ok) { - let detail = ""; - try { - const raw = await res.text(); - console.log("[compose] error body:", raw); - detail = raw.slice(0, 500); - } catch { - detail = res.statusText; + // Group selected channels by workspace + const byWorkspace = new Map(); + 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: [] }); } - throw new Error(`HTTP ${res.status}: ${detail}`); + 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 = { + type: postNow ? "now" : "schedule", + date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), + shortLink: false, + tags: [] as string[], + posts: channelIds.map((integrationId) => ({ + integration: { id: integrationId }, + value: [{ content: content.trim(), image: media }], + })), + }; + + const body = JSON.stringify(payload); + console.log("[compose] POST", `${ws.baseUrl}/posts`, body); + + // eslint-disable-next-line no-undef + const res = await globalThis.fetch(`${ws.baseUrl}/posts`, { + method: "POST", + headers: { Authorization: ws.apiKey, "Content-Type": "application/json" }, + body, + }); + + if (!res.ok) { + let detail = ""; + try { + const raw = await res.text(); + console.log(`[compose][${ws.name}] error body:`, raw); + detail = raw.slice(0, 500); + } catch { detail = res.statusText; } + throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`); + } + }) + ); + + if (hasLocalImages) setUploading(false); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); await AsyncStorage.removeItem(DRAFT_STORAGE_KEY); Alert.alert( @@ -289,6 +386,7 @@ export default function ComposeScreen() { queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] }); } catch (e: unknown) { + setUploading(false); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); const msg = e instanceof Error ? e.message : "Could not submit post."; Alert.alert("Failed", msg); @@ -317,7 +415,7 @@ export default function ComposeScreen() { Not Configured - Add your API key in Settings + Add a workspace in Settings ); } @@ -395,7 +493,7 @@ export default function ComposeScreen() { const uri = item.type === "local" ? item.uri - : resolveMediaUrl(item.path, baseUrl); + : resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? ""); return ( )} + {/* Channels grouped by workspace then network type */} CHANNELS {loadingIntegrations ? ( - ) : (integrations ?? []).length === 0 ? ( + ) : grouped.length === 0 ? ( No channels found. Add integrations in your Postiz instance. ) : ( - - {(integrations ?? []).map((intg) => ( - toggleChannel(intg.id)} - /> + + {grouped.map(({ workspace, networks }, wsIdx) => ( + 0 && { marginTop: 8 }, + ]} + > + {/* Workspace header */} + + + + {workspace.name} + + + + {/* Network groups */} + + {networks.map(({ label, channels }) => ( + + {networks.length > 1 && ( + + {label} + + )} + + {channels.map((intg) => ( + toggleChannel(intg.id)} + /> + ))} + + + ))} + + ))} - + )} @@ -577,13 +708,20 @@ export default function ComposeScreen() { setShowMediaLibrary(false)} - onSelect={(items) => { + onSelect={(items: LibraryMediaItem[]) => { 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); }} @@ -600,29 +738,64 @@ const styles = StyleSheet.create({ charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 }, charCountLabel: { fontSize: 10, 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" }, 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" }, imageRow: { gap: 10, paddingRight: 4 }, imageThumbWrap: { position: "relative" }, 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" }, - uploadedBadge: { position: "absolute", bottom: 4, left: 4, width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center" }, + removeImg: { + 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 }, - 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" }, 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 }, + channelGroups: { gap: 0 }, + 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 }, 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 }, + 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 }, + submitBtn: { + flexDirection: "row", alignItems: "center", justifyContent: "center", + gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4, + }, submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" }, }); diff --git a/artifacts/postiz-mobile/app/(tabs)/settings.tsx b/artifacts/postiz-mobile/app/(tabs)/settings.tsx index 253eabf..bf48012 100644 --- a/artifacts/postiz-mobile/app/(tabs)/settings.tsx +++ b/artifacts/postiz-mobile/app/(tabs)/settings.tsx @@ -1,7 +1,7 @@ import { Feather } from "@expo/vector-icons"; import axios from "axios"; import * as Haptics from "expo-haptics"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { ActivityIndicator, Alert, @@ -15,49 +15,73 @@ import { } from "react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; 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 { 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() { const colors = useColors(); const insets = useSafeAreaInsets(); - const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz(); + const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz(); - const [inputKey, setInputKey] = useState(apiKey); - const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL); + const [form, setForm] = useState(null); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle"); - const [errorDetail, setErrorDetail] = useState(""); + const [errorDetail, setErrorDetail] = useState(""); - useEffect(() => { - setInputKey(apiKey); - setInputUrl(baseUrl || DEFAULT_BASE_URL); - }, [apiKey, baseUrl]); + const openAdd = () => { + setForm(EMPTY_FORM); + setShowKey(false); + 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) => { + setForm((prev) => (prev ? { ...prev, ...patch } : prev)); + resetValidation(); + }; 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."); return; } setValidating(true); - setValidationStatus("idle"); - setErrorDetail(""); - const cleanUrl = inputUrl.trim().replace(/\/$/, ""); + resetValidation(); + const cleanUrl = form.url.trim().replace(/\/$/, ""); + const variants = [form.key.trim(), `Bearer ${form.key.trim()}`]; + let lastError = ""; - const authVariants = [ - inputKey.trim(), - `Bearer ${inputKey.trim()}`, - ]; - - let lastError: string = ""; - - for (const authHeader of authVariants) { + for (const auth of variants) { try { await axios.get(`${cleanUrl}/integrations`, { - headers: { Authorization: authHeader }, + headers: { Authorization: auth }, timeout: 10000, maxRedirects: 0, }); @@ -67,21 +91,17 @@ export default function SettingsScreen() { return; } catch (err: unknown) { if (axios.isAxiosError(err)) { - const status = err.response?.status; - if (status === 307 || status === 301 || status === 302 || status === 308) { - const location = 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.`; - continue; - } - if (status === 401 || status === 403) { - lastError = `HTTP ${status}: Invalid or expired API key.`; + const s = err.response?.status; + if (s === 307 || s === 301 || s === 302 || s === 308) { + const loc = err.response?.headers?.location ?? "unknown"; + lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`; continue; } + if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; } } lastError = extractError(err); } } - setErrorDetail(lastError); setValidationStatus("error"); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); @@ -89,37 +109,38 @@ export default function SettingsScreen() { }; const handleSave = async () => { - if (!inputKey.trim() || !inputUrl.trim()) { - Alert.alert("Missing fields", "Please enter both API key and base URL."); - return; - } + if (!form) return; + if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; } + if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; } setSaving(true); 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); - Alert.alert("Saved", "Settings saved successfully."); + closeForm(); } catch (err: unknown) { - Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`); + Alert.alert("Error", `Failed to save.\n${extractError(err)}`); } finally { setSaving(false); } }; - const handleClear = () => { + const handleDelete = (ws: PostizWorkspace) => { Alert.alert( - "Disconnect", - "Remove your API key and disconnect from Postiz?", + "Remove workspace", + `Remove "${ws.name}"? Channels from this workspace will no longer be available.`, [ { text: "Cancel", style: "cancel" }, { - text: "Disconnect", + text: "Remove", style: "destructive", onPress: async () => { - await clearSettings(); - setInputKey(""); - setInputUrl(DEFAULT_BASE_URL); - setValidationStatus("idle"); - setErrorDetail(""); + if (form?.id === ws.id) closeForm(); + await removeWorkspace(ws.id); }, }, ] @@ -140,281 +161,251 @@ export default function SettingsScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - {!isConfigured && ( + {/* Status banner */} + {!isConfigured ? ( - Connect to your Postiz instance to get started + Add a workspace to get started - )} - - {isConfigured && ( + ) : ( - Connected to Postiz + {workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured )} - - BASE URL - - - { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - /> - - - - - API KEY - - - { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }} - secureTextEntry={!showKey} - autoCapitalize="none" - autoCorrect={false} - /> - setShowKey((v) => !v)} activeOpacity={0.7}> - - - - - {validationStatus === "ok" && ( - - - - Connection successful - - - )} - - {validationStatus === "error" && ( - - - - - Could not connect - - - {!!errorDetail && ( - - - {errorDetail} + {/* Workspace cards */} + {workspaces.map((ws) => ( + + + + + + + + {ws.name} + + {ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")} - - )} + + + + openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}> + + + handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}> + + + - )} - + + ))} - - {validating ? ( - - ) : ( - <> - - - Test Connection - - - )} - - - - {saving ? ( - - ) : ( - <> - - - Save Settings - - - )} - - - {isConfigured && ( + {/* Add workspace button */} + {!form && ( - - Disconnect + + Add workspace )} - - - Your API key is stored securely on this device and never transmitted to third parties. - - + {/* Add / Edit form */} + {form && ( + + + {form.id ? "Edit workspace" : "Add workspace"} + + + {/* Name */} + + NAME + + + patchForm({ name: t })} + autoCorrect={false} + /> + + + + {/* Base URL */} + + BASE URL + + + patchForm({ url: t })} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + + + {/* API Key */} + + API KEY + + + patchForm({ key: t })} + secureTextEntry={!showKey} + autoCapitalize="none" + autoCorrect={false} + /> + setShowKey((v) => !v)} activeOpacity={0.7}> + + + + + {validationStatus === "ok" && ( + + + Connection successful + + )} + + {validationStatus === "error" && ( + + + + Could not connect + + {!!errorDetail && ( + + {errorDetail} + + )} + + )} + + + {/* Form actions */} + + {validating ? ( + + ) : ( + <> + + Test Connection + + )} + + + + + Cancel + + + {saving ? ( + + ) : ( + <> + + + {form.id ? "Update" : "Save"} + + + )} + + + + )} + + + API keys are stored securely on this device and never transmitted to third parties. + ); } const styles = StyleSheet.create({ - container: { - paddingHorizontal: 20, - gap: 16, - }, + container: { paddingHorizontal: 20, gap: 14 }, banner: { - flexDirection: "row", - alignItems: "center", - gap: 10, - paddingHorizontal: 14, - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1, - }, - bannerText: { - fontSize: 13, - fontFamily: "Inter_500Medium", - flex: 1, + 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", + 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, + connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" }, + wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" }, + wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 }, + wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 }, + wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" }, + wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, + wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 }, + wsCardActions: { flexDirection: "row", gap: 4 }, + iconBtn: { padding: 8 }, + addBtn: { + flexDirection: "row", alignItems: "center", justifyContent: "center", + 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: { - 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", - }, - 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, + flexDirection: "row", alignItems: "center", + borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10, }, + 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: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1, - }, - validateText: { - fontSize: 14, - fontFamily: "Inter_600SemiBold", + flexDirection: "row", alignItems: "center", justifyContent: "center", + gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1, }, + 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: { - 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, + flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center", + gap: 8, paddingVertical: 12, borderRadius: 10, }, + saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, + footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 }, }); diff --git a/artifacts/postiz-mobile/components/MediaLibraryModal.tsx b/artifacts/postiz-mobile/components/MediaLibraryModal.tsx index 3c49154..6aa2292 100644 --- a/artifacts/postiz-mobile/components/MediaLibraryModal.tsx +++ b/artifacts/postiz-mobile/components/MediaLibraryModal.tsx @@ -5,15 +5,24 @@ import { ActivityIndicator, FlatList, Modal, + ScrollView, StyleSheet, Text, TouchableOpacity, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { PostizWorkspace } from "@/context/PostizContext"; import { useColors } from "@/hooks/useColors"; -interface MediaItem { +export interface LibraryMediaItem { + id: string; + path: string; + workspaceId: string; + createdAt?: string; +} + +interface RawMediaItem { id: string; path: string; createdAt?: string; @@ -21,11 +30,11 @@ interface MediaItem { interface Props { visible: boolean; - baseUrl: string; - apiKey: string; + workspaces: PostizWorkspace[]; + defaultWorkspaceId?: string; maxSelect: number; onClose: () => void; - onSelect: (items: MediaItem[]) => void; + onSelect: (items: LibraryMediaItem[]) => void; } function resolveUrl(path: string, baseUrl: string): string { @@ -34,26 +43,37 @@ function resolveUrl(path: string, baseUrl: string): string { 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 insets = useSafeAreaInsets(); - const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(""); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState>(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 () => { - if (!baseUrl || !apiKey) return; + if (!activeWorkspace) return; setLoading(true); setError(null); try { // eslint-disable-next-line no-undef - const res = await globalThis.fetch(`${baseUrl}/media`, { - headers: { Authorization: apiKey }, + const res = await globalThis.fetch(`${activeWorkspace.baseUrl}/media`, { + headers: { Authorization: activeWorkspace.apiKey }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); - const list: MediaItem[] = Array.isArray(data) + const list: RawMediaItem[] = Array.isArray(data) ? data : (data?.media ?? data?.items ?? data?.files ?? []); setItems(list); @@ -62,35 +82,36 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose } finally { setLoading(false); } - }, [baseUrl, apiKey]); + }, [activeWorkspace]); useEffect(() => { - if (visible) { + if (visible && activeWorkspace) { setSelected(new Set()); load(); } - }, [visible, load]); + }, [visible, activeWorkspace, load]); const toggle = (id: string) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else if (next.size < maxSelect) { - next.add(id); - } + if (next.has(id)) { next.delete(id); } + else if (next.size < maxSelect) { next.add(id); } return next; }); }; 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); }; return ( + {/* Header */} @@ -100,10 +121,7 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose onPress={handleConfirm} disabled={selected.size === 0} activeOpacity={0.8} - style={[ - styles.addBtn, - { backgroundColor: selected.size > 0 ? colors.primary : colors.muted }, - ]} + style={[styles.addBtn, { backgroundColor: selected.size > 0 ? colors.primary : colors.muted }]} > {selected.size > 0 ? `Add ${selected.size}` : "Add"} @@ -111,6 +129,36 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose + {/* Workspace tabs (only shown when >1 workspace) */} + {workspaces.length > 1 && ( + + {workspaces.map((ws) => { + const active = ws.id === activeId; + return ( + setActiveId(ws.id)} + activeOpacity={0.7} + style={[ + styles.tab, + active && { borderBottomColor: colors.primary, borderBottomWidth: 2 }, + ]} + > + + {ws.name} + + + ); + })} + + )} + + {/* Content */} {loading ? ( @@ -119,11 +167,7 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose {error} - + Retry @@ -140,18 +184,10 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]} renderItem={({ item }) => { const isSelected = selected.has(item.id); - const uri = resolveUrl(item.path, baseUrl); + const uri = resolveUrl(item.path, activeWorkspace?.baseUrl ?? ""); return ( - toggle(item.id)} - activeOpacity={0.8} - style={styles.cell} - > - + toggle(item.id)} activeOpacity={0.8} style={styles.cell}> + {isSelected && ( @@ -174,17 +210,18 @@ const CELL = 120; const styles = StyleSheet.create({ root: { flex: 1 }, header: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 16, - paddingVertical: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - gap: 12, + flexDirection: "row", alignItems: "center", + paddingHorizontal: 16, paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, gap: 12, }, closeBtn: { padding: 4 }, title: { flex: 1, fontSize: 17, fontFamily: "Inter_600SemiBold" }, addBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 }, 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 }, errorText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center", paddingHorizontal: 32 }, emptyText: { fontSize: 14, fontFamily: "Inter_400Regular" }, @@ -194,16 +231,8 @@ const styles = StyleSheet.create({ cell: { width: CELL, height: CELL, margin: 2 }, cellImage: { width: CELL, height: CELL, borderRadius: 4 }, selectedOverlay: { - ...StyleSheet.absoluteFillObject, - borderRadius: 4, - alignItems: "center", - justifyContent: "center", - }, - checkCircle: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", + ...StyleSheet.absoluteFillObject, borderRadius: 4, + alignItems: "center", justifyContent: "center", }, + checkCircle: { width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center" }, }); diff --git a/artifacts/postiz-mobile/context/PostizContext.tsx b/artifacts/postiz-mobile/context/PostizContext.tsx index 92e4258..a0959d7 100644 --- a/artifacts/postiz-mobile/context/PostizContext.tsx +++ b/artifacts/postiz-mobile/context/PostizContext.tsx @@ -5,14 +5,22 @@ import React, { useCallback, useContext, useEffect, - useRef, useState, } from "react"; -const API_KEY_STORAGE = "postiz_api_key"; -const BASE_URL_STORAGE = "postiz_base_url"; +const WORKSPACES_KEY = "postiz_workspaces_v2"; +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 interface PostizWorkspace { + id: string; + name: string; + apiKey: string; + baseUrl: string; +} + export interface PostizIntegration { id: string; name: string; @@ -45,129 +53,129 @@ export interface PostizUploadResult { } interface PostizContextValue { - apiKey: string; - baseUrl: string; + workspaces: PostizWorkspace[]; isConfigured: boolean; isLoading: boolean; - unauthorized: boolean; - clearUnauthorized: () => void; + clients: Record; + addWorkspace: (ws: Omit) => Promise; + updateWorkspace: (ws: PostizWorkspace) => Promise; + removeWorkspace: (id: string) => Promise; + // backward compat for posts.tsx (first workspace) client: AxiosInstance | null; - saveSettings: (apiKey: string, baseUrl: string) => Promise; - clearSettings: () => Promise; + apiKey: string; + baseUrl: string; } const PostizContext = createContext({ - apiKey: "", - baseUrl: DEFAULT_BASE_URL, + workspaces: [], isConfigured: false, isLoading: true, - unauthorized: false, - clearUnauthorized: () => {}, + clients: {}, + addWorkspace: async () => {}, + updateWorkspace: async () => {}, + removeWorkspace: async () => {}, client: null, - saveSettings: async () => {}, - clearSettings: async () => {}, + apiKey: "", + baseUrl: DEFAULT_BASE_URL, }); -function createClient( - apiKey: string, - baseUrl: string, - onUnauthorized?: () => void -): AxiosInstance { - const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; +function makeClient(ws: PostizWorkspace): AxiosInstance { + const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/"; const instance = axios.create({ - baseURL: normalizedUrl, - headers: { - Authorization: apiKey, - "Content-Type": "application/json", - }, + baseURL, + headers: { Authorization: ws.apiKey, "Content-Type": "application/json" }, timeout: 15000, }); 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; }); - instance.interceptors.response.use( - (res) => res, - (err) => { - if (axios.isAxiosError(err) && err.response?.status === 401) { - onUnauthorized?.(); - } - return Promise.reject(err); - } - ); return instance; } +function buildClients(list: PostizWorkspace[]): Record { + return Object.fromEntries(list.map((ws) => [ws.id, makeClient(ws)])); +} + export function PostizProvider({ children }: { children: React.ReactNode }) { - const [apiKey, setApiKey] = useState(""); - const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); + const [workspaces, setWorkspaces] = useState([]); + const [clients, setClients] = useState>({}); const [isLoading, setIsLoading] = useState(true); - const [client, setClient] = useState(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(() => { (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).replace(/\/$/, ""); - setApiKey(storedKey); - setBaseUrl(url); - setClient(() => createClient(storedKey, url, handleUnauthorized)); + const stored = await SecureStore.getItemAsync(WORKSPACES_KEY); + if (stored) { + const list: PostizWorkspace[] = JSON.parse(stored); + setWorkspaces(list); + setClients(buildClients(list)); + } else { + // 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 { } finally { setIsLoading(false); } })(); - }, [handleUnauthorized]); + }, []); - 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); - clearUnauthorized(); - setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized)); + const persist = useCallback(async (list: PostizWorkspace[]) => { + await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list)); + setWorkspaces(list); + setClients(buildClients(list)); + }, []); + + const addWorkspace = useCallback( + async (ws: Omit) => { + await persist([...workspaces, { ...ws, id: Date.now().toString() }]); }, - [handleUnauthorized, clearUnauthorized] + [workspaces, persist] ); - const clearSettings = useCallback(async () => { - await SecureStore.deleteItemAsync(API_KEY_STORAGE); - await SecureStore.deleteItemAsync(BASE_URL_STORAGE); - setApiKey(""); - setBaseUrl(DEFAULT_BASE_URL); - setClient(null); - clearUnauthorized(); - }, [clearUnauthorized]); + const updateWorkspace = useCallback( + async (ws: PostizWorkspace) => { + await persist(workspaces.map((w) => (w.id === ws.id ? ws : w))); + }, + [workspaces, persist] + ); + + const removeWorkspace = useCallback( + async (id: string) => { + await persist(workspaces.filter((w) => w.id !== id)); + }, + [workspaces, persist] + ); + + const primaryWorkspace = workspaces[0] ?? null; return ( 0, isLoading, - unauthorized, - clearUnauthorized, - client, - saveSettings, - clearSettings, + clients, + addWorkspace, + updateWorkspace, + removeWorkspace, + client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null, + apiKey: primaryWorkspace?.apiKey ?? "", + baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL, }} > {children}