Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 157 additions & 7 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { NavigationContainer } from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";

import { fetchCatalog, fetchPublisherResources, fetchRegistryStatus, getApiBaseUrl } from "./src/api/resources";
import { PublisherSettings } from "./src/components/PublisherSettings";
import { ResourceCard } from "./src/components/ResourceCard";
import { getApiKey } from "./src/services/secureStorage";
import {
fetchCatalog,
fetchRegistryStatus,
Expand All @@ -25,6 +29,14 @@ export default function App() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [settingsVisible, setSettingsVisible] = useState(false);
const [publisherMode, setPublisherMode] = useState(false);
const [hasApiKey, setHasApiKey] = useState(false);

const checkApiKey = useCallback(async () => {
const key = await getApiKey();
setHasApiKey(!!key);
}, []);
const [settingsOpen, setSettingsOpen] = useState(false);
const [apiBaseUrl, setApiBaseUrlState] = useState<string>("");
const [apiUrlInput, setApiUrlInput] = useState<string>("");
Expand All @@ -38,12 +50,17 @@ export default function App() {
setError(null);

try {
const [catalog, registry] = await Promise.all([
fetchCatalog(),
fetchRegistryStatus().catch(() => null),
]);
setResources(catalog);
setRegistryCount(registry?.resourceCount ?? null);
if (publisherMode) {
const publisherResources = await fetchPublisherResources();
setResources(publisherResources);
} else {
const [catalog, registry] = await Promise.all([
fetchCatalog(),
fetchRegistryStatus().catch(() => null),
]);
setResources(catalog);
setRegistryCount(registry?.resourceCount ?? null);
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong loading the catalog.";
Expand All @@ -52,7 +69,7 @@ export default function App() {
setLoading(false);
setRefreshing(false);
}
}, []);
}, [publisherMode]);

const loadSettings = useCallback(async () => {
const loadedUrl = await initializeApiBaseUrl();
Expand Down Expand Up @@ -80,6 +97,10 @@ export default function App() {
}
}, [apiUrlInput, loadData]);

useEffect(() => {
void checkApiKey();
}, [checkApiKey]);

useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 2500);
Expand All @@ -92,6 +113,11 @@ export default function App() {
return resources.filter((resource) => resource.title.toLowerCase().includes(query));
}, [resources, search]);

const handleApiKeySet = useCallback(() => {
void checkApiKey();
void loadData();
}, [checkApiKey, loadData]);

function renderEmpty() {
if (loading) return null;

Expand All @@ -117,6 +143,96 @@ export default function App() {
return (
<SafeAreaProvider>
<StatusBar style="dark" />
<FlatList
data={filteredResources}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => void loadData(true)} />
}
ListHeaderComponent={
<View style={styles.header}>
<View style={styles.titleRow}>
<View>
<Text style={typography.title}>MindVault</Text>
<Text style={typography.subtitle}>
Payment-protected digital resources on Stellar
</Text>
{registryCount !== null ? (
<Text style={styles.registry}>
{registryCount} resource{registryCount === 1 ? "" : "s"} on-chain
</Text>
) : null}
</View>
<Pressable onPress={() => setSettingsVisible(true)} style={styles.settingsButton}>
<Text style={styles.settingsButtonText}>⚙️</Text>
</Pressable>
</View>

<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search resources…"
placeholderTextColor={colors.textSubtle}
style={styles.searchInput}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>

<Text style={styles.apiHint}>API: {getApiBaseUrl()}</Text>

{hasApiKey ? (
<Pressable
onPress={() => setPublisherMode(!publisherMode)}
style={({ pressed }) => [
styles.modeToggle,
publisherMode && styles.modeToggleActive,
pressed && styles.buttonPressed,
]}
>
<Text style={[styles.modeToggleText, publisherMode && styles.modeToggleActiveText]}>
{publisherMode ? "📚 View Catalog" : "👤 Publisher Mode"}
</Text>
</Pressable>
) : null}

{error ? (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{error}</Text>
<Pressable onPress={() => void loadData()} style={styles.retryButton}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : null}

{loading ? (
<View style={styles.loadingRow}>
<ActivityIndicator color={colors.primary} />
<Text style={typography.body}>Loading catalog…</Text>
</View>
) : null}
</View>
}
renderItem={({ item }) => (
<ResourceCard resource={item} onCopyUrl={setToast} />
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={renderEmpty}
/>

{toast ? (
<View style={styles.toast}>
<Text style={styles.toastText}>{toast}</Text>
</View>
) : null}

<PublisherSettings
visible={settingsVisible}
onClose={() => setSettingsVisible(false)}
onApiKeySet={handleApiKeySet}
/>
</SafeAreaView>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
Expand All @@ -134,6 +250,16 @@ const styles = StyleSheet.create({
gap: spacing.md,
marginBottom: spacing.lg,
},
titleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
settingsButton: {
padding: spacing.sm,
},
settingsButtonText: {
fontSize: 24,
headerTop: {
flexDirection: "row",
justifyContent: "space-between",
Expand All @@ -149,6 +275,30 @@ const styles = StyleSheet.create({
fontWeight: "600",
color: colors.primary,
},
modeToggle: {
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: "center",
},
modeToggleActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
buttonPressed: {
opacity: 0.7,
},
modeToggleText: {
color: colors.text,
fontWeight: "600",
fontSize: 15,
},
modeToggleActiveText: {
color: "#ffffff",
},
searchInput: {
borderWidth: 1,
borderColor: colors.border,
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"expo-camera": "~16.0.0",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.8",
"expo-secure-store": "^56.0.4",
"expo-sharing": "^56.0.18",
"expo-status-bar": "~2.0.1",
"react": "18.3.1",
Expand Down
27 changes: 27 additions & 0 deletions src/api/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { loadApiBaseUrl, saveApiBaseUrl } from "./apiSettings";
import Constants from "expo-constants";
import { Keypair, Transaction } from "stellar-sdk";

import { getApiKey } from "../services/secureStorage";
import type { CatalogFilters, RegistryStatus, Resource } from "../types";
import { logError } from "../utils/errorLogger";

Expand Down Expand Up @@ -156,3 +157,29 @@ export async function submitOwnershipTransfer(
return res.json();
}

export async function fetchPublisherResources(): Promise<Resource[]> {
const apiKey = await getApiKey();
if (!apiKey) {
throw new Error("No API key configured");
}

const res = await fetch(`${API_BASE}/publishers/me/resources`, {
headers: {
"x-api-key": apiKey,
},
});

if (res.status === 401) {
throw new Error("Unauthorized: Invalid API key");
}

if (res.status === 403) {
throw new Error("Forbidden: Access denied");
}

if (!res.ok) {
throw new Error("Failed to fetch publisher resources");
}

return res.json();
}
Loading
Loading