feat: multi-workspace support + channels grouped by workspace and network
Release APK / build (push) Has been cancelled
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:
@@ -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" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user