5 Commits

Author SHA1 Message Date
billisdead 0cf5800463 fix(mobile): restore images on repost, improve media library 404 error, null-safe stripHtml
Release APK / build (push) Has been cancelled
- Pass post.image[] as JSON prefillImages param when prefilling compose from an existing post (repost/edit/retry), replacing the broken single-image prefillImagePath/prefillImageId approach
- MediaLibraryModal now shows the attempted URL and a clear explanation on 404, making it easier to diagnose if the Postiz version does not expose GET /media
- stripHtml accepts null/undefined input and returns "" instead of throwing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:31:34 +02:00
billisdead 20ca6e0334 feat(compose): group channels by customer with tap-to-select-all
Release APK / build (push) Has been cancelled
Channels in the compose picker are now grouped by customer (as returned
by the Postiz API). Tapping a customer header selects or deselects all
its channels at once. Individual channel chips still toggle as before.
Workspace header is hidden when only one workspace is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:54:49 +02:00
billisdead 9abd05d05a feat(compose): tap-to-select-all workspace + flat channel chips
Release APK / build (push) Has been cancelled
- Workspace header is now a TouchableOpacity: tap selects all its
  channels, re-tap deselects all (partial state shows minus-square icon)
- Removed sub-grouping by network type — channels are displayed as a
  flat chip row directly under each workspace card
- Removed unused networkLabel helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:46:35 +02:00
billisdead 59b688dafb feat: add dynamic changelog bullet points to GitHub release notes
Parse feat/fix commits since previous tag and render them as
'What's New' and 'Bug Fixes' sections in the release body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:32:41 +02:00
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
7 changed files with 851 additions and 558 deletions
+24 -2
View File
@@ -99,6 +99,29 @@ jobs:
APK=$(ls artifacts/postiz-mobile/dist/*.apk | sort | tail -1) APK=$(ls artifacts/postiz-mobile/dist/*.apk | sort | tail -1)
echo "path=$APK" >> "$GITHUB_OUTPUT" echo "path=$APK" >> "$GITHUB_OUTPUT"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -1)
echo "Previous tag: $PREV_TAG"
FEATS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^feat(\([^)]+\))?: " \
| sed -E 's/^feat(\([^)]+\))?: //' \
| sed 's/^/- /')
FIXES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^fix(\([^)]+\))?: " \
| sed -E 's/^fix(\([^)]+\))?: //' \
| sed 's/^/- /')
{
echo "changelog<<CEOF"
[ -n "$FEATS" ] && printf "### What's New\n%s\n\n" "$FEATS"
[ -n "$FIXES" ] && printf "### Bug Fixes\n%s\n\n" "$FIXES"
echo "CEOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -106,8 +129,7 @@ jobs:
body: | body: |
## Postiz Mobile ${{ github.ref_name }} ## Postiz Mobile ${{ github.ref_name }}
Signed APK for Android — direct install (sideload). ${{ steps.changelog.outputs.changelog }}
### Installation ### Installation
1. Enable "Unknown sources" on the device 1. Enable "Unknown sources" on the device
2. Transfer the APK to the device and open it to install 2. Transfer the APK to the device and open it to install
+350 -118
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,17 @@ 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 };
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,14 +66,13 @@ 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, prefillImages } =
useLocalSearchParams<{ useLocalSearchParams<{
prefillContent?: string; prefillContent?: string;
prefillIntegrationIds?: string; prefillIntegrationIds?: string;
prefillImagePath?: string; prefillImages?: string;
prefillImageId?: string;
}>(); }>();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
@@ -82,10 +94,23 @@ export default function ComposeScreen() {
if (prefillIntegrationIds) { if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
if (prefillImagePath && prefillImageId) { if (prefillImages && workspaces.length > 0) {
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath) }]); try {
const images: Array<{ id: string; path: string }> = JSON.parse(String(prefillImages));
const wsId = workspaces[0]?.id ?? "";
setMediaItems(
images
.filter((img) => img?.id && img?.path)
.map((img): MediaItem => ({
type: "uploaded",
id: img.id,
path: img.path,
workspaceId: wsId,
}))
);
} catch {}
} }
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]); }, [prefillContent, prefillIntegrationIds, prefillImages, workspaces]);
useEffect(() => { useEffect(() => {
if (prefillContent) return; if (prefillContent) return;
@@ -98,21 +123,90 @@ 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 () => {
if (!client) return []; const results = await Promise.all(
const res = await client.get("integrations"); workspaces.map(async (ws) => {
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? []; 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, staleTime: 60000,
}); });
const effectiveCharLimit = (() => { type CustomerGroup = {
if (selectedChannels.length === 0 || !integrations) return 3000; customerId: string;
const selected = integrations.filter((i) => selectedChannels.includes(i.id)); customerName: string;
channels: IntegrationWithWorkspace[];
};
type WorkspaceGroup = {
workspace: PostizWorkspace;
customers: CustomerGroup[];
allChannels: IntegrationWithWorkspace[];
};
// Group: workspace → customers → channels
const grouped = useMemo((): WorkspaceGroup[] => {
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 allChannels = byWorkspace.get(ws.id)!;
const byCustomer = new Map<string, CustomerGroup>();
for (const ch of allChannels) {
const cId = ch.customer?.id ?? "__default__";
const cName = ch.customer?.name ?? ws.name;
if (!byCustomer.has(cId)) byCustomer.set(cId, { customerId: cId, customerName: cName, channels: [] });
byCustomer.get(cId)!.channels.push(ch);
}
return { workspace: ws, customers: Array.from(byCustomer.values()), allChannels };
});
}, [allIntegrations, workspaces]);
const toggleWorkspace = (wsId: string) => {
const allIds = (grouped.find((g) => g.workspace.id === wsId)?.allChannels ?? []).map((c) => c.id);
const allSelected = allIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !allIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...allIds])]);
}
};
const toggleCustomer = (customerIds: string[]) => {
const allSelected = customerIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !customerIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...customerIds])]);
}
};
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 +215,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 +287,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[],
const result: Array<{ id: string; path: string }> = []; ws: PostizWorkspace
for (const item of mediaItems) { ): Promise<Array<{ id: string; path: string }>> => {
if (item.type === "uploaded") { const result: Array<{ id: string; path: string }> = [];
result.push({ id: item.id, path: item.path }); for (const uri of localUris) {
continue; const formData = new FormData();
} if (Platform.OS === "web") {
const formData = new FormData(); const response = await expoFetch(uri);
if (Platform.OS === "web") { const blob = await response.blob();
const response = await expoFetch(item.uri); formData.append("file", blob, "upload.jpg");
const blob = await response.blob(); } else {
formData.append("file", blob, "upload.jpg"); formData.append("file", {
} else { uri,
formData.append("file", { name: "upload.jpg",
uri: item.uri, type: "image/jpeg",
name: "upload.jpg", } as unknown as Blob);
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 });
} }
return result; // eslint-disable-next-line no-undef
} finally { const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
setUploading(false); 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 () => { const handleSubmit = async () => {
@@ -245,40 +334,71 @@ 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 payload = { const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
type: postNow ? "now" : "schedule", for (const channelId of selectedChannels) {
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), const intg = allIntegrations?.find((i) => i.id === channelId);
shortLink: false, if (!intg) continue;
tags: [] as string[], if (!byWorkspace.has(intg.workspaceId)) {
posts: selectedChannels.map((integrationId) => ({ byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
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;
} }
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); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY); await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
@@ -289,6 +409,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 +438,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 +516,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,29 +564,95 @@ 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, customers, allChannels }, wsIdx) => {
showsHorizontalScrollIndicator={false} const wsAllIds = allChannels.map((c) => c.id);
contentContainerStyle={styles.channelList} const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
> const wsAllSelected = wsSelectedCount === wsAllIds.length;
{(integrations ?? []).map((intg) => ( const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
<ChannelChip return (
key={intg.id} <View
integration={intg} key={workspace.id}
selected={selectedChannels.includes(intg.id)} style={[
onToggle={() => toggleChannel(intg.id)} styles.workspaceSection,
/> { backgroundColor: colors.card, borderColor: colors.border },
))} wsIdx > 0 && { marginTop: 8 },
</ScrollView> ]}
>
{/* Workspace header — tap to select/deselect all */}
{workspaces.length > 1 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleWorkspace(workspace.id)}
style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}
>
<Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary, flex: 1 }]}>
{workspace.name}
</Text>
<Feather
name={wsAllSelected ? "check-square" : wsSomeSelected ? "minus-square" : "square"}
size={14}
color={wsAllSelected || wsSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
)}
{/* Customer sub-sections */}
{customers.map((cust, cIdx) => {
const custIds = cust.channels.map((c) => c.id);
const custSelectedCount = custIds.filter((id) => selectedChannels.includes(id)).length;
const custAllSelected = custSelectedCount === custIds.length;
const custSomeSelected = custSelectedCount > 0 && !custAllSelected;
return (
<View
key={cust.customerId}
style={cIdx > 0 ? [styles.customerSection, { borderTopColor: colors.border }] : undefined}
>
{/* Customer header — tap to select/deselect all channels for this customer */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleCustomer(custIds)}
style={styles.customerHeader}
>
<Text style={[styles.customerName, { color: colors.foreground }]}>
{cust.customerName}
</Text>
<Feather
name={custAllSelected ? "check-square" : custSomeSelected ? "minus-square" : "square"}
size={14}
color={custAllSelected || custSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
{/* Channel chips for this customer */}
<View style={styles.chipRow}>
{cust.channels.map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
))}
</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 +764,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 +794,67 @@ 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 },
customerSection: { borderTopWidth: StyleSheet.hairlineWidth },
customerHeader: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingHorizontal: 12, paddingVertical: 8,
},
customerName: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: 10, paddingBottom: 10 },
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" },
}); });
+8 -7
View File
@@ -152,13 +152,14 @@ export default function PostsScreen() {
const handlePrefillCompose = (post: PostizPost) => { const handlePrefillCompose = (post: PostizPost) => {
const integrations = post.integrations ?? (post.integration ? [post.integration] : []); const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
router.push({ const params: Record<string, string> = {
pathname: "/(tabs)/compose", prefillContent: stripHtml(post.content),
params: { prefillIntegrationIds: integrations.map((i) => i.id).join(","),
prefillContent: stripHtml(post.content), };
prefillIntegrationIds: integrations.map((i) => i.id).join(","), if (post.image?.length) {
}, params.prefillImages = JSON.stringify(post.image);
}); }
router.push({ pathname: "/(tabs)/compose", params });
}; };
const startReschedule = (post: PostizPost) => { const startReschedule = (post: PostizPost) => {
+279 -288
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,281 +161,251 @@ 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 */}
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text> {workspaces.map((ws) => (
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <View style={styles.wsCardHeader}>
<TextInput <View style={styles.wsCardLeft}>
style={[styles.input, { color: colors.foreground }]} <View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
placeholder="https://postiz.example.com/api/public/v1" <Feather name="briefcase" size={14} color={colors.primary} />
placeholderTextColor={colors.mutedForeground} </View>
value={inputUrl} <View>
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} <Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
autoCapitalize="none" <Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
autoCorrect={false} {ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
keyboardType="url"
/>
</View>
</View>
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={inputKey}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validationRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}>
Connection successful
</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>
Could not connect
</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>
{errorDetail}
</Text> </Text>
</ScrollView> </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>
)} </View>
</View> ))}
<TouchableOpacity {/* Add workspace button */}
onPress={handleValidate} {!form && (
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>
Test Connection
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings
</Text>
</>
)}
</TouchableOpacity>
{isConfigured && (
<TouchableOpacity <TouchableOpacity
onPress={handleClear} onPress={openAdd}
activeOpacity={0.8} activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]} style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
> >
<Feather name="log-out" size={14} color={colors.destructive} /> <Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text> <Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<View style={styles.footer}> {/* Add / Edit form */}
<Text style={[styles.footerText, { color: colors.mutedForeground }]}> {form && (
Your API key is stored securely on this device and never transmitted to third parties. <View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
</Text> <Text style={[styles.formTitle, { color: colors.foreground }]}>
</View> {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>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground}
value={form.url}
onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</View>
{/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={form.key}
onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
</ScrollView>
)}
</View>
)}
</View>
{/* Form actions */}
<TouchableOpacity
onPress={handleValidate}
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
</>
)}
</TouchableOpacity>
<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
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
{form.id ? "Update" : "Save"}
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
)}
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
API keys are stored securely on this device and never transmitted to third parties.
</Text>
</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,45 @@ 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);
const url = `${activeWorkspace.baseUrl}/media`;
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(url, {
headers: { Authorization: apiKey }, headers: { Authorization: activeWorkspace.apiKey },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) {
if (res.status === 404) {
throw new Error(
`Media listing endpoint not found (404).\nURL tried: ${url}\n\nThis feature requires Postiz to expose GET /media in its public API. Your version may not support it yet.`
);
}
throw new Error(`HTTP ${res.status}${url}`);
}
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 +90,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 +129,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 +137,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 +175,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 +192,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 +218,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 +239,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;
@@ -20,6 +28,7 @@ export interface PostizIntegration {
picture?: string; picture?: string;
identifier?: string; identifier?: string;
internalType?: string; internalType?: string;
customer?: { id: string; name: string };
} }
export interface PostizMediaItem { export interface PostizMediaItem {
@@ -45,129 +54,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}
+2 -1
View File
@@ -1,4 +1,5 @@
export function stripHtml(html: string): string { export function stripHtml(html: string | null | undefined): string {
if (!html) return "";
// Decode entities first so encoded tags like &lt;p&gt; are also stripped // Decode entities first so encoded tags like &lt;p&gt; are also stripped
let s = html let s = html
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")