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>
This commit is contained in:
2026-06-11 14:50:20 +02:00
parent d4c16ccf97
commit 8b7a2eb644
4 changed files with 743 additions and 542 deletions
@@ -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<MediaItem[]>([]);
const [activeId, setActiveId] = useState<string>("");
const [items, setItems] = useState<RawMediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
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 () => {
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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onClose} activeOpacity={0.7} style={styles.closeBtn}>
<Feather name="x" size={20} color={colors.foreground} />
@@ -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 }]}
>
<Text style={[styles.addBtnText, { color: colors.primaryForeground }]}>
{selected.size > 0 ? `Add ${selected.size}` : "Add"}
@@ -111,6 +129,36 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
</TouchableOpacity>
</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 ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
@@ -119,11 +167,7 @@ export function MediaLibraryModal({ visible, baseUrl, apiKey, maxSelect, onClose
<View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>{error}</Text>
<TouchableOpacity
onPress={load}
style={[styles.retryBtn, { backgroundColor: colors.primary }]}
activeOpacity={0.8}
>
<TouchableOpacity onPress={load} style={[styles.retryBtn, { backgroundColor: colors.primary }]} activeOpacity={0.8}>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>Retry</Text>
</TouchableOpacity>
</View>
@@ -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 (
<TouchableOpacity
onPress={() => toggle(item.id)}
activeOpacity={0.8}
style={styles.cell}
>
<Image
source={{ uri }}
style={styles.cellImage}
contentFit="cover"
/>
<TouchableOpacity onPress={() => toggle(item.id)} activeOpacity={0.8} style={styles.cell}>
<Image source={{ uri }} style={styles.cellImage} contentFit="cover" />
{isSelected && (
<View style={[styles.selectedOverlay, { backgroundColor: colors.primary + "99" }]}>
<View style={[styles.checkCircle, { backgroundColor: colors.primary }]}>
@@ -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" },
});