import { Feather } from "@expo/vector-icons"; import { Image } from "expo-image"; import React, { useCallback, useEffect, useState } from "react"; 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"; export interface LibraryMediaItem { id: string; path: string; workspaceId: string; createdAt?: string; } interface RawMediaItem { id: string; path: string; createdAt?: string; } interface Props { visible: boolean; workspaces: PostizWorkspace[]; defaultWorkspaceId?: string; maxSelect: number; onClose: () => void; onSelect: (items: LibraryMediaItem[]) => void; onPickFromDevice?: () => void; } function resolveUrl(path: string, baseUrl: string): string { if (path.startsWith("http://") || path.startsWith("https://")) return path; const origin = baseUrl.replace(/\/api\/.*$/, ""); return `${origin}/${path.replace(/^\//, "")}`; } export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect, onPickFromDevice }: Props) { const colors = useColors(); const insets = useSafeAreaInsets(); const [activeId, setActiveId] = useState(""); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState>(new Set()); const activeWorkspace = workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null; useEffect(() => { if (visible) { const initial = defaultWorkspaceId ?? workspaces[0]?.id ?? ""; setActiveId(initial); setSelected(new Set()); } }, [visible, defaultWorkspaceId, workspaces]); const load = useCallback(async () => { if (!activeWorkspace) return; setLoading(true); setError(null); const apiBase = activeWorkspace.baseUrl.replace(/\/public\/v1$/, ""); const url = `${apiBase}/media?page=0&search=`; try { // eslint-disable-next-line no-undef const res = await globalThis.fetch(url, { headers: { Authorization: activeWorkspace.apiKey }, }); if (!res.ok) { if (res.status === 401 || res.status === 403) { throw new Error("SESSION_REQUIRED"); } if (res.status === 404) { throw new Error("ENDPOINT_NOT_FOUND"); } throw new Error(`HTTP ${res.status} — ${url}`); } const data = await res.json(); const list: RawMediaItem[] = Array.isArray(data) ? data : (data?.media ?? data?.items ?? data?.files ?? []); setItems(list); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to load media"); } finally { setLoading(false); } }, [activeWorkspace]); useEffect(() => { if (visible && activeWorkspace) { setSelected(new Set()); 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); } return next; }); }; const handleConfirm = () => { if (!activeWorkspace) return; const chosen = items .filter((i) => selected.has(i.id)) .map((i): LibraryMediaItem => ({ ...i, workspaceId: activeWorkspace.id })); onSelect(chosen); }; return ( {/* Header */} Media Library 0 ? colors.primary : colors.muted }]} > {selected.size > 0 ? `Add ${selected.size}` : "Add"} {/* Workspace tabs (only shown when >1 workspace) */} {workspaces.length > 1 && ( {workspaces.map((ws) => { const active = ws.id === activeId; return ( setActiveId(ws.id)} activeOpacity={0.7} style={[ styles.tab, active && { borderBottomColor: colors.primary, borderBottomWidth: 2 }, ]} > {ws.name} ); })} )} {/* Content */} {loading ? ( ) : error === "SESSION_REQUIRED" ? ( {"Media library requires a web session.\nAPI key access is not supported by Postiz."} {onPickFromDevice && ( { onClose(); onPickFromDevice(); }} style={[styles.retryBtn, { backgroundColor: colors.primary }]} activeOpacity={0.8} > Use device gallery )} ) : error === "ENDPOINT_NOT_FOUND" ? ( {"Media library endpoint not found on this server."} ) : error ? ( {error} Retry ) : items.length === 0 ? ( No media found ) : ( item.id} numColumns={3} contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]} renderItem={({ item }) => { const isSelected = selected.has(item.id); const uri = resolveUrl(item.path, activeWorkspace?.baseUrl ?? ""); return ( toggle(item.id)} activeOpacity={0.8} style={styles.cell}> {isSelected && ( )} ); }} /> )} ); } const CELL = 120; const styles = StyleSheet.create({ root: { flex: 1 }, header: { 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" }, retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, grid: { padding: 2 }, 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" }, });