mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 09:56:43 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
46
apps/context/apps/mobile/app/+html.tsx
Normal file
46
apps/context/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
24
apps/context/apps/mobile/app/+not-found.tsx
Normal file
24
apps/context/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { Container } from '~/components/Container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className={styles.title}>This screen doesn't exist.</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
74
apps/context/apps/mobile/app/_layout.tsx
Normal file
74
apps/context/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import '../global.css';
|
||||
import { useEffect } from 'react';
|
||||
import { Slot, Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { SplashScreen } from 'expo-router';
|
||||
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||
import { ThemeProvider } from '../components/theme';
|
||||
import { DebugProvider } from '../context/DebugContext';
|
||||
import { I18nProvider } from '../context/I18nContext';
|
||||
import { initializeRevenueCat } from '../services/revenueCatService';
|
||||
import '../utils/i18n'; // Initialize i18n
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
// Komponente zur Überprüfung der Authentifizierung und Weiterleitung
|
||||
function RootLayoutNav() {
|
||||
const { user, loading } = useAuth();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
const [fontsLoaded, fontError] = useFonts({
|
||||
// You can add custom fonts here if needed
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fontsLoaded || fontError) {
|
||||
// Hide the splash screen after the fonts have loaded (or an error was reported)
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [fontsLoaded, fontError]);
|
||||
|
||||
// Prevent rendering until the fonts have loaded or an error was encountered
|
||||
if (!fontsLoaded && !fontError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authentifizierungslogik
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
const isAuthRoute = segments[0] === 'login' || segments[0] === 'register';
|
||||
|
||||
if (!user && !isAuthRoute) {
|
||||
// Wenn der Benutzer nicht angemeldet ist und nicht auf einer Auth-Seite ist, leite zur Login-Seite weiter
|
||||
router.replace('/login');
|
||||
} else if (user && isAuthRoute) {
|
||||
// Wenn der Benutzer angemeldet ist und auf einer Auth-Seite ist, leite zur Startseite weiter
|
||||
router.replace('/');
|
||||
}
|
||||
|
||||
// Initialisiere RevenueCat, wenn der Benutzer angemeldet ist
|
||||
if (user) {
|
||||
initializeRevenueCat(user.id);
|
||||
}
|
||||
}, [user, loading, segments, router]);
|
||||
|
||||
return <Slot />;
|
||||
}
|
||||
|
||||
// Root-Layout mit AuthProvider, ThemeProvider, I18nProvider und DebugProvider
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<DebugProvider>
|
||||
<RootLayoutNav />
|
||||
</DebugProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
17
apps/context/apps/mobile/app/details.tsx
Normal file
17
apps/context/apps/mobile/app/details.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
|
||||
import { Container } from '~/components/Container';
|
||||
import { ScreenContent } from '~/components/ScreenContent';
|
||||
|
||||
export default function Details() {
|
||||
const { name } = useLocalSearchParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Details' }} />
|
||||
<Container>
|
||||
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
apps/context/apps/mobile/app/index.tsx
Normal file
374
apps/context/apps/mobile/app/index.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import {
|
||||
View,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { Screen } from '~/components/layout/Screen';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useAuth } from '~/context/AuthContext';
|
||||
import { getSpaces, getDocuments, Document, Space } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { useTranslations } from '~/context/I18nContext';
|
||||
import { SpaceFilterPill } from '~/components/spaces/SpaceFilterPill';
|
||||
import { AllSpacesFilterPill } from '~/components/spaces/AllSpacesFilterPill';
|
||||
import { SpaceFilterPillSkeleton } from '~/components/spaces/SpaceFilterPillSkeleton';
|
||||
import { DocumentTypeBadge } from '~/components/documents/DocumentTypeBadge';
|
||||
import { DocumentGallery } from '~/components/documents/DocumentGallery';
|
||||
import { SpaceCreator } from '~/components/spaces/SpaceCreator';
|
||||
import { InlineSpaceCreator } from '~/components/spaces/InlineSpaceCreator';
|
||||
import { Breadcrumbs } from '~/components/navigation/Breadcrumbs';
|
||||
import {
|
||||
DocumentTypeFilterDropdown,
|
||||
FilterType,
|
||||
} from '~/components/documents/DocumentTypeFilterDropdown';
|
||||
import { FilterPill } from '~/components/ui/FilterPill';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { isDark } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDesktop = width > 1024;
|
||||
const { t, homepage, spaces: spacesT, common, errors } = useTranslations();
|
||||
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedSpaceIds, setSelectedSpaceIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSpaceCreator, setShowSpaceCreator] = useState(false);
|
||||
const [showInlineCreator, setShowInlineCreator] = useState(false);
|
||||
const [selectedDocumentType, setSelectedDocumentType] = useState<FilterType | null>(null);
|
||||
|
||||
// Optimierte Sortierfunktion
|
||||
const sortDocuments = useCallback((docs: Document[]) => {
|
||||
return docs.sort((a, b) => {
|
||||
// Zuerst nach Pin-Status sortieren (angepinnte zuerst)
|
||||
if ((a.pinned || false) && !(b.pinned || false)) return -1;
|
||||
if (!(a.pinned || false) && (b.pinned || false)) return 1;
|
||||
|
||||
// Bei gleichem Pin-Status nach Aktualisierungsdatum sortieren (neueste zuerst)
|
||||
const dateA = new Date(a.updated_at || a.created_at);
|
||||
const dateB = new Date(b.updated_at || b.created_at);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Funktion zum Laden der Daten
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Alle Spaces laden
|
||||
const spacesData = await getSpaces();
|
||||
setSpaces(spacesData);
|
||||
|
||||
// Alle Dokumente aus allen Spaces laden (parallel)
|
||||
let allDocuments: Document[] = [];
|
||||
|
||||
if (spacesData.length > 0) {
|
||||
// Wenn keine Spaces ausgewählt sind, alle Dokumente laden
|
||||
if (selectedSpaceIds.length === 0) {
|
||||
const documentPromises = spacesData.map((space) => getDocuments(space.id));
|
||||
const documentResults = await Promise.all(documentPromises);
|
||||
allDocuments = documentResults.flat();
|
||||
} else {
|
||||
// Nur Dokumente aus ausgewählten Spaces laden
|
||||
const documentPromises = selectedSpaceIds.map((spaceId) => getDocuments(spaceId));
|
||||
const documentResults = await Promise.all(documentPromises);
|
||||
allDocuments = documentResults.flat();
|
||||
}
|
||||
}
|
||||
|
||||
// Dokumente sortieren
|
||||
const sortedDocuments = sortDocuments(allDocuments);
|
||||
setDocuments(sortedDocuments);
|
||||
} catch (err: any) {
|
||||
console.error('Fehler beim Laden der Daten:', err);
|
||||
setError(homepage('errorLoadingData'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [selectedSpaceIds]);
|
||||
|
||||
// Lade die Daten beim ersten Rendern und wenn sich die ausgewählten Spaces ändern
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadData();
|
||||
}
|
||||
}, [user, loadData, selectedSpaceIds]);
|
||||
|
||||
// Funktion zum Aktualisieren der Daten (Pull-to-Refresh)
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// Funktion zum Umschalten eines Space-Filters (Single-Select)
|
||||
const toggleSpaceFilter = (spaceId: string | null) => {
|
||||
// Wenn null ("Alle") oder der bereits ausgewählte Space angeklickt wird, alle deselektieren
|
||||
if (spaceId === null || (selectedSpaceIds.length === 1 && selectedSpaceIds[0] === spaceId)) {
|
||||
setSelectedSpaceIds([]);
|
||||
} else {
|
||||
// Sonst nur den angeklickten Space auswählen
|
||||
setSelectedSpaceIds([spaceId]);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtere Dokumente basierend auf der Suche und dem ausgewählten Dokumenttyp
|
||||
const filteredDocuments = useMemo(() => {
|
||||
return documents.filter((doc) => {
|
||||
const titleMatch = doc.title?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const contentMatch = doc.content?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const typeMatch = !selectedDocumentType || doc.type === selectedDocumentType;
|
||||
return (titleMatch || contentMatch) && typeMatch;
|
||||
});
|
||||
}, [documents, searchQuery, selectedDocumentType]);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: homepage('title'),
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<Screen
|
||||
scrollable={false}
|
||||
padded={false}
|
||||
style={{ flex: 1, height: '100%' }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#6366f1']}
|
||||
tintColor="#6366f1"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Hauptcontainer ohne Breitenbegrenzung */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
paddingTop: 4, // Optimaler Abstand oben
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Breadcrumbs mit Suchfunktion und Settings-Icon */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
backgroundColor: isDark ? '#111827' : '#f9fafb',
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: isDesktop ? 800 : '100%',
|
||||
width: '100%',
|
||||
marginHorizontal: 'auto',
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: homepage('title'), href: undefined },
|
||||
{
|
||||
label: homepage('selectSpace'),
|
||||
dropdownItems: spaces.map((space) => ({
|
||||
id: space.id,
|
||||
label: space.name,
|
||||
href: `/spaces/${space.id}`,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
showSettingsIcon={false}
|
||||
className="justify-between"
|
||||
loading={loading}
|
||||
rightComponent={
|
||||
<DocumentTypeFilterDropdown
|
||||
selectedType={selectedDocumentType}
|
||||
onTypeChange={setSelectedDocumentType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Filter-Bereich mit Space-Filtern */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
height: 28,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center', // Zentriert die Inhalte horizontal
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
{/* Space Filter Skeleton */}
|
||||
<FilterPill
|
||||
label={spacesT('newSpace')}
|
||||
icon="add"
|
||||
variant="action"
|
||||
disabled={true}
|
||||
style={{ opacity: 0.7 }}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
<SpaceFilterPillSkeleton count={3} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Space Filter */}
|
||||
{spaces.length > 0 ? (
|
||||
<>
|
||||
{/* Neuer Space Button oder Inline Creator */}
|
||||
{showInlineCreator ? (
|
||||
<InlineSpaceCreator
|
||||
onCancel={() => setShowInlineCreator(false)}
|
||||
onCreated={(spaceId) => {
|
||||
setShowInlineCreator(false);
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FilterPill
|
||||
label={spacesT('newSpace')}
|
||||
icon="add"
|
||||
variant="action"
|
||||
onPress={() => setShowInlineCreator(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Alle" Filter Pill */}
|
||||
<AllSpacesFilterPill
|
||||
isSelected={selectedSpaceIds.length === 0}
|
||||
onPress={() => toggleSpaceFilter(null)}
|
||||
/>
|
||||
|
||||
{/* Space Filter Pills - nur gepinnte Spaces anzeigen */}
|
||||
{spaces
|
||||
.filter((space) => space.pinned)
|
||||
.map((space) => (
|
||||
<SpaceFilterPill
|
||||
key={space.id}
|
||||
id={space.id}
|
||||
name={space.name}
|
||||
isSelected={
|
||||
selectedSpaceIds.length === 1 && selectedSpaceIds[0] === space.id
|
||||
}
|
||||
onPress={toggleSpaceFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Neuer Space Button oder Inline Creator */}
|
||||
{showInlineCreator ? (
|
||||
<InlineSpaceCreator
|
||||
onCancel={() => setShowInlineCreator(false)}
|
||||
onCreated={(spaceId) => {
|
||||
setShowInlineCreator(false);
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FilterPill
|
||||
label={spacesT('newSpace')}
|
||||
icon="add"
|
||||
variant="action"
|
||||
onPress={() => setShowInlineCreator(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Alle" Filter Pill */}
|
||||
<AllSpacesFilterPill
|
||||
isSelected={true}
|
||||
onPress={() => toggleSpaceFilter(null)}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
fontSize: 14,
|
||||
fontStyle: 'italic',
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{spacesT('noSpaces')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Dokumente als Galerie anzeigen */}
|
||||
<View style={{ flex: 1, position: 'relative' }}>
|
||||
<DocumentGallery
|
||||
documents={filteredDocuments}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
selectedSpaceIds={selectedSpaceIds}
|
||||
onCreateDocument={() => {
|
||||
if (selectedSpaceIds.length === 1) {
|
||||
// Wenn genau ein Space ausgewählt ist, navigiere zur Dokumenterstellung für diesen Space
|
||||
router.push(`/spaces/${selectedSpaceIds[0]}/documents/create?mode=edit`);
|
||||
} else if (selectedSpaceIds.length === 0 && spaces.length > 0) {
|
||||
// Wenn kein Space ausgewählt ist, aber Spaces vorhanden sind, nehme den ersten Space
|
||||
router.push(`/spaces/${spaces[0].id}/documents/create?mode=edit`);
|
||||
} else if (spaces.length > 0) {
|
||||
// Wenn mehrere Spaces ausgewählt sind, nehme den ersten ausgewählten Space
|
||||
router.push(`/spaces/${selectedSpaceIds[0]}/documents/create?mode=edit`);
|
||||
} else {
|
||||
// Wenn keine Spaces vorhanden sind, zeige eine Meldung an
|
||||
alert(spacesT('createSpaceFirst'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Settings Icon (unten rechts) */}
|
||||
<Ionicons
|
||||
name="settings-outline"
|
||||
size={24}
|
||||
color={isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
right: 20,
|
||||
}}
|
||||
onPress={() => router.push('/settings')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Screen>
|
||||
|
||||
{/* Space Creator Modal */}
|
||||
<SpaceCreator
|
||||
visible={showSpaceCreator}
|
||||
onClose={() => setShowSpaceCreator(false)}
|
||||
onCreated={(spaceId) => {
|
||||
// Space wurde erstellt, füge ihn zur Auswahl hinzu
|
||||
setSelectedSpaceIds([spaceId]);
|
||||
// Lade die Daten neu
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
apps/context/apps/mobile/app/login.tsx
Normal file
36
apps/context/apps/mobile/app/login.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { View } from 'react-native';
|
||||
import { LoginForm } from '~/components/auth/LoginForm';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
|
||||
export default function LoginScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Anmelden',
|
||||
headerShown: true,
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900 p-4 justify-center">
|
||||
<View className="w-full max-w-md mx-auto">
|
||||
<Card className="p-6">
|
||||
<View className="items-center mb-6">
|
||||
<Text variant="h1" className="text-center mb-2">
|
||||
BaseText
|
||||
</Text>
|
||||
<Text variant="body" className="text-center text-gray-600 dark:text-gray-400">
|
||||
Melde dich an, um deine Texte zu verwalten
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<LoginForm />
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
apps/context/apps/mobile/app/register.tsx
Normal file
35
apps/context/apps/mobile/app/register.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { View } from 'react-native';
|
||||
import { RegisterForm } from '~/components/auth/RegisterForm';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Registrieren',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900 p-4 justify-center">
|
||||
<View className="w-full max-w-md mx-auto">
|
||||
<Card className="p-6">
|
||||
<View className="items-center mb-6">
|
||||
<Text variant="h1" className="text-center mb-2">
|
||||
BaseText
|
||||
</Text>
|
||||
<Text variant="body" className="text-center text-gray-600 dark:text-gray-400">
|
||||
Erstelle ein Konto, um mit BaseText zu starten
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<RegisterForm />
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
286
apps/context/apps/mobile/app/settings/index.tsx
Normal file
286
apps/context/apps/mobile/app/settings/index.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Alert, Switch } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { ThemeSelector } from '~/components/theme';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { useAuth } from '~/context/AuthContext';
|
||||
import { useDebug } from '~/context/DebugContext';
|
||||
import { useTranslations } from '~/context/I18nContext';
|
||||
import { LanguagePicker } from '~/components/settings/LanguagePicker';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
/**
|
||||
* Einstellungsseite
|
||||
* Ermöglicht die Konfiguration der App-Einstellungen, wie z.B. das Theme
|
||||
*/
|
||||
export default function SettingsScreen() {
|
||||
const { isDark } = useTheme();
|
||||
const { signOut, user } = useAuth();
|
||||
const { showDebugBorders, toggleDebugBorders } = useDebug();
|
||||
const { t, settings, auth, common } = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert(auth('signOut'), 'Are you sure you want to sign out?', [
|
||||
{
|
||||
text: common('cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: auth('signOut'),
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace('/login');
|
||||
},
|
||||
style: 'destructive',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: settings('settings'),
|
||||
headerStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: isDark ? '#111827' : '#f9fafb' }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
{/* Benutzerinfo */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{settings('account')}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
styles.userCard,
|
||||
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
]}
|
||||
>
|
||||
<View style={styles.userInfo}>
|
||||
<View
|
||||
style={[styles.userAvatar, { backgroundColor: isDark ? '#4b5563' : '#e5e7eb' }]}
|
||||
>
|
||||
<Ionicons name="person" size={24} color={isDark ? '#d1d5db' : '#6b7280'} />
|
||||
</View>
|
||||
<View style={styles.userDetails}>
|
||||
<Text style={[styles.userName, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{user?.email || 'User'}
|
||||
</Text>
|
||||
<Text style={[styles.userEmail, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
|
||||
{user?.email || auth('noEmailAddress')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Token-Management */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.card,
|
||||
styles.tokenButton,
|
||||
{ backgroundColor: isDark ? '#1f2937' : '#ffffff', marginTop: 12 },
|
||||
]}
|
||||
onPress={() => router.push('/tokens')}
|
||||
>
|
||||
<View style={styles.tokenContent}>
|
||||
<Ionicons name="wallet-outline" size={24} color={isDark ? '#818cf8' : '#4f46e5'} />
|
||||
<Text style={[styles.tokenText, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{settings('tokenManagement')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={styles.arrowIcon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Erscheinungsbild */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{settings('appearance')}
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
<ThemeSelector />
|
||||
</View>
|
||||
<View style={[styles.card, { marginTop: 12 }]}>
|
||||
<LanguagePicker />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Entwickleroptionen */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{settings('developer')}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.card, { backgroundColor: isDark ? '#1f2937' : '#ffffff', padding: 16 }]}
|
||||
>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingLabelContainer}>
|
||||
<Ionicons
|
||||
name="grid-outline"
|
||||
size={20}
|
||||
color={isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={styles.settingIcon}
|
||||
/>
|
||||
<Text style={[styles.settingLabel, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
Show Debug Borders
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={showDebugBorders}
|
||||
onValueChange={toggleDebugBorders}
|
||||
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
|
||||
thumbColor={showDebugBorders ? '#4f46e5' : '#f9fafb'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.settingDescription, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
|
||||
Shows colored borders around UI elements for development and debugging
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Abmelden */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{settings('session')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.card,
|
||||
styles.logoutButton,
|
||||
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<View style={styles.logoutContent}>
|
||||
<Ionicons name="log-out-outline" size={24} color="#ef4444" />
|
||||
<Text style={styles.logoutText}>{auth('signOut')}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
settingLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
settingIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
userCard: {
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// Token-Button Styles
|
||||
tokenButton: {
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
tokenContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tokenText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 12,
|
||||
flex: 1,
|
||||
},
|
||||
arrowIcon: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
userAvatar: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
userDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 16,
|
||||
},
|
||||
logoutContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutText: {
|
||||
marginLeft: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#ef4444',
|
||||
},
|
||||
});
|
||||
1040
apps/context/apps/mobile/app/spaces/[id]/documents/[documentId].tsx
Normal file
1040
apps/context/apps/mobile/app/spaces/[id]/documents/[documentId].tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,80 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
import { colors } from '~/utils/theme';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
/**
|
||||
* Styles für die Dokumentenseite
|
||||
* Verwendet das zentrale Theme-System für konsistente Farben
|
||||
*/
|
||||
export const documentStyles = StyleSheet.create({
|
||||
noFocusRing: {
|
||||
// On React Native, focus rings are not rendered - no styles needed
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.gray[100],
|
||||
},
|
||||
fullHeightContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* NativeWind-Klassen für die Dokumentenseite
|
||||
* Ermöglicht eine einfache Verwendung des Theme-Systems mit NativeWind
|
||||
*/
|
||||
export const getDocumentClasses = (themeName = 'blue') => {
|
||||
return {
|
||||
// Container und Layout
|
||||
container: 'flex-1 flex-col',
|
||||
contentContainer: 'flex-1 px-4',
|
||||
|
||||
// Hintergrundfarben
|
||||
background: 'bg-white dark:bg-gray-900',
|
||||
backgroundSecondary: 'bg-gray-50 dark:bg-gray-800',
|
||||
|
||||
// Breadcrumbs-Container
|
||||
breadcrumbsContainer:
|
||||
'px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 w-full',
|
||||
|
||||
// Toolbars und Aktionsleisten
|
||||
toolbar:
|
||||
'flex-row justify-between items-center px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700',
|
||||
|
||||
// Editoren und Textfelder
|
||||
editor: 'p-4 bg-white dark:bg-gray-900 min-h-[200px]',
|
||||
editorBorder: 'border border-gray-200 dark:border-gray-700 rounded-md',
|
||||
|
||||
// Vorschau
|
||||
preview: 'p-4 bg-white dark:bg-gray-900',
|
||||
previewBorder: 'border border-gray-200 dark:border-gray-700 rounded-md',
|
||||
|
||||
// Buttons und interaktive Elemente
|
||||
button: `bg-${themeName}-500 hover:bg-${themeName}-600 text-white py-2 px-4 rounded-md`,
|
||||
buttonSecondary:
|
||||
'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 py-2 px-4 rounded-md',
|
||||
|
||||
// Text
|
||||
text: 'text-gray-900 dark:text-gray-100',
|
||||
textSecondary: 'text-gray-600 dark:text-gray-400',
|
||||
|
||||
// Formular-Elemente
|
||||
input: `bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-${themeName}-500 focus:border-${themeName}-500`,
|
||||
|
||||
// Zustände
|
||||
active: `bg-${themeName}-500 text-white`,
|
||||
inactive: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook zum Abrufen der Dokumenten-Klassen basierend auf dem aktuellen Theme
|
||||
* @returns Ein Objekt mit vordefinierten Tailwind-Klassen für das aktuelle Theme
|
||||
*/
|
||||
export function useDocumentClasses() {
|
||||
const { themeName } = useTheme();
|
||||
return getDocumentClasses(themeName);
|
||||
}
|
||||
|
||||
// Für Abwärtskompatibilität
|
||||
export const documentClasses = getDocumentClasses();
|
||||
1104
apps/context/apps/mobile/app/spaces/[id]/index.tsx
Normal file
1104
apps/context/apps/mobile/app/spaces/[id]/index.tsx
Normal file
File diff suppressed because it is too large
Load diff
104
apps/context/apps/mobile/app/spaces/create/index.tsx
Normal file
104
apps/context/apps/mobile/app/spaces/create/index.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { Screen } from '~/components/layout/Screen';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
import { Button } from '~/components/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { createSpace } from '~/services/supabaseService';
|
||||
|
||||
export default function CreateSpaceScreen() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreateSpace = async () => {
|
||||
// Validierung
|
||||
if (!name.trim()) {
|
||||
setError('Bitte gib einen Namen für den Space ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error } = await createSpace(name.trim(), description.trim() || undefined);
|
||||
|
||||
if (error) {
|
||||
setError(`Fehler beim Erstellen des Space: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Erfolgreich erstellt, navigiere zurück zur Space-Übersicht
|
||||
router.replace('/spaces');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Neuen Space erstellen',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Screen scrollable padded>
|
||||
<Card className="p-4">
|
||||
{error && (
|
||||
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<Text className="text-red-800 dark:text-red-200">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="Name des Space"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
className="mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Beschreibung (optional)"
|
||||
placeholder="Beschreibung des Space"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<View className="flex-row justify-end space-x-4">
|
||||
<Button
|
||||
title="Abbrechen"
|
||||
onPress={() => router.back()}
|
||||
className="bg-gray-300 dark:bg-gray-700"
|
||||
/>
|
||||
<Button
|
||||
title={loading ? 'Wird erstellt...' : 'Space erstellen'}
|
||||
onPress={handleCreateSpace}
|
||||
disabled={loading || !name.trim()}
|
||||
className={loading || !name.trim() ? 'opacity-70' : ''}
|
||||
>
|
||||
{loading && (
|
||||
<ActivityIndicator size="small" color="#ffffff" style={{ marginLeft: 8 }} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</Card>
|
||||
</Screen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
apps/context/apps/mobile/app/spaces/index.tsx
Normal file
130
apps/context/apps/mobile/app/spaces/index.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import { View, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Screen } from '~/components/layout/Screen';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { SpaceCard } from '~/components/spaces/SpaceCard';
|
||||
import { SearchBar } from '~/components/functional/SearchBar';
|
||||
import { EmptyState } from '~/components/layout/EmptyState';
|
||||
import { Button } from '~/components/Button';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getSpaces, Space } from '~/services/supabaseService';
|
||||
|
||||
// Definiere den Typ für einen Space mit zusätzlichen Informationen für die UI
|
||||
type UISpace = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
documentCount: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export default function SpacesScreen() {
|
||||
const router = useRouter();
|
||||
const [spaces, setSpaces] = useState<UISpace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Funktion zum Laden der Spaces
|
||||
const loadSpaces = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const spaces = await getSpaces();
|
||||
|
||||
// Transformiere die Daten in das UI-Format
|
||||
const uiSpaces: UISpace[] = spaces.map((space) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
documentCount: 0, // Wird später durch eine separate Abfrage aktualisiert
|
||||
tags: space.settings?.tags || [],
|
||||
}));
|
||||
|
||||
setSpaces(uiSpaces);
|
||||
} catch (err: any) {
|
||||
setError('Fehler beim Laden der Spaces: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Lade die Spaces beim ersten Rendern
|
||||
useEffect(() => {
|
||||
loadSpaces();
|
||||
}, [loadSpaces]);
|
||||
|
||||
// Funktion zum Aktualisieren der Spaces (Pull-to-Refresh)
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadSpaces();
|
||||
}, [loadSpaces]);
|
||||
|
||||
// Funktion zum Filtern der Spaces nach Suchbegriff
|
||||
const filteredSpaces = spaces.filter(
|
||||
(space) =>
|
||||
space.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(space.description && space.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const hasSpaces = filteredSpaces.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Spaces', headerShown: true }} />
|
||||
<Screen
|
||||
scrollable
|
||||
padded
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#6366f1']}
|
||||
tintColor="#6366f1"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SearchBar placeholder="Spaces durchsuchen..." onSearch={setSearchQuery} />
|
||||
|
||||
{error && (
|
||||
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<Text className="text-red-800 dark:text-red-200">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text variant="h2">Alle Spaces</Text>
|
||||
<Button title="Neu" onPress={() => router.push('/spaces/create')} />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 justify-center items-center py-8">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
</View>
|
||||
) : hasSpaces ? (
|
||||
<View>
|
||||
{filteredSpaces.map((space) => (
|
||||
<SpaceCard key={space.id} {...space} />
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={searchQuery ? 'Keine Spaces gefunden' : 'Noch keine Spaces vorhanden'}
|
||||
description={
|
||||
searchQuery
|
||||
? 'Es wurden keine Spaces gefunden, die deiner Suche entsprechen.'
|
||||
: 'Erstelle deinen ersten Space, um deine Dokumente zu organisieren.'
|
||||
}
|
||||
icon={<Ionicons name="folder-outline" size={48} color="#6366f1" />}
|
||||
actionLabel="Space erstellen"
|
||||
onAction={() => router.push('/spaces/create')}
|
||||
/>
|
||||
)}
|
||||
</Screen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
617
apps/context/apps/mobile/app/tokens/index.tsx
Normal file
617
apps/context/apps/mobile/app/tokens/index.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { supabase } from '~/utils/supabase';
|
||||
import {
|
||||
getCurrentTokenBalance,
|
||||
getTokenTransactions,
|
||||
getTokenUsageStats,
|
||||
} from '~/services/tokenTransactionService';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import TokenStore from '~/components/monetization/TokenStore';
|
||||
|
||||
export default function TokenManagementScreen() {
|
||||
const { isDark } = useTheme();
|
||||
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [usageStats, setUsageStats] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeframe, setTimeframe] = useState<'day' | 'week' | 'month' | 'year'>('month');
|
||||
const [storeVisible, setStoreVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Hole den aktuellen Benutzer
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Nicht angemeldet');
|
||||
}
|
||||
|
||||
// Hole das Token-Guthaben
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
setTokenBalance(balance);
|
||||
|
||||
// Hole die Token-Transaktionen
|
||||
const transactionData = await getTokenTransactions(userId, 20);
|
||||
setTransactions(transactionData);
|
||||
|
||||
// Hole die Nutzungsstatistiken
|
||||
const stats = await getTokenUsageStats(userId, timeframe);
|
||||
setUsageStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Token-Daten:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [timeframe, storeVisible]); // Aktualisieren, wenn der Store geschlossen wird
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getTransactionTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'usage':
|
||||
return 'Nutzung';
|
||||
case 'purchase':
|
||||
return 'Kauf';
|
||||
case 'monthly_reset':
|
||||
return 'Monatliches Kontingent';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionColor = (type: string, amount: number) => {
|
||||
if (amount > 0) {
|
||||
return isDark ? '#10b981' : '#059669'; // Grün für positive Beträge
|
||||
} else {
|
||||
return isDark ? '#ef4444' : '#dc2626'; // Rot für negative Beträge
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeframeChange = (newTimeframe: 'day' | 'week' | 'month' | 'year') => {
|
||||
setTimeframe(newTimeframe);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isDark ? styles.containerDark : styles.containerLight]}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Token-Verwaltung',
|
||||
headerStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollViewContent}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={isDark ? '#f9fafb' : '#1f2937'} />
|
||||
<Text style={[styles.loadingText, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Lade Token-Daten...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Token-Guthaben */}
|
||||
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
|
||||
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Aktuelles Token-Guthaben
|
||||
</Text>
|
||||
<Text style={[styles.balanceText, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
{tokenBalance !== null ? tokenBalance.toLocaleString() : '---'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isDark ? styles.buttonDark : styles.buttonLight]}
|
||||
onPress={() => setStoreVisible(true)}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: isDark ? '#ffffff' : '#ffffff' }]}>
|
||||
Tokens kaufen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Nutzungsstatistiken */}
|
||||
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
|
||||
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Token-Nutzung
|
||||
</Text>
|
||||
|
||||
<View style={styles.timeframeSelector}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.timeframeButton,
|
||||
timeframe === 'day' && styles.timeframeButtonActive,
|
||||
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
|
||||
timeframe === 'day' &&
|
||||
(isDark
|
||||
? styles.timeframeButtonActiveDark
|
||||
: styles.timeframeButtonActiveLight),
|
||||
]}
|
||||
onPress={() => handleTimeframeChange('day')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.timeframeButtonText,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
timeframe === 'day' && styles.timeframeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Tag
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.timeframeButton,
|
||||
timeframe === 'week' && styles.timeframeButtonActive,
|
||||
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
|
||||
timeframe === 'week' &&
|
||||
(isDark
|
||||
? styles.timeframeButtonActiveDark
|
||||
: styles.timeframeButtonActiveLight),
|
||||
]}
|
||||
onPress={() => handleTimeframeChange('week')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.timeframeButtonText,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
timeframe === 'week' && styles.timeframeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Woche
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.timeframeButton,
|
||||
timeframe === 'month' && styles.timeframeButtonActive,
|
||||
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
|
||||
timeframe === 'month' &&
|
||||
(isDark
|
||||
? styles.timeframeButtonActiveDark
|
||||
: styles.timeframeButtonActiveLight),
|
||||
]}
|
||||
onPress={() => handleTimeframeChange('month')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.timeframeButtonText,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
timeframe === 'month' && styles.timeframeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Monat
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.timeframeButton,
|
||||
timeframe === 'year' && styles.timeframeButtonActive,
|
||||
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
|
||||
timeframe === 'year' &&
|
||||
(isDark
|
||||
? styles.timeframeButtonActiveDark
|
||||
: styles.timeframeButtonActiveLight),
|
||||
]}
|
||||
onPress={() => handleTimeframeChange('year')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.timeframeButtonText,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
timeframe === 'year' && styles.timeframeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
Jahr
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{usageStats && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statLabel, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Gesamtnutzung:
|
||||
</Text>
|
||||
<Text style={[styles.statValue, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
{usageStats.totalUsed.toLocaleString()} Tokens
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Nach Modell:
|
||||
</Text>
|
||||
|
||||
{Object.entries(usageStats.byModel).length > 0 ? (
|
||||
Object.entries(usageStats.byModel).map(([model, amount]: [string, any]) => (
|
||||
<View key={model} style={styles.statItem}>
|
||||
<Text style={[styles.statLabel, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
{model}:
|
||||
</Text>
|
||||
<Text style={[styles.statValue, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
{amount.toLocaleString()} Tokens
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Keine Nutzungsdaten für diesen Zeitraum
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Transaktionshistorie */}
|
||||
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
|
||||
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
|
||||
Transaktionshistorie
|
||||
</Text>
|
||||
|
||||
{transactions.length > 0 ? (
|
||||
transactions.map((transaction, index) => (
|
||||
<View
|
||||
key={transaction.id}
|
||||
style={[
|
||||
styles.transactionItem,
|
||||
index < transactions.length - 1 && styles.transactionItemBorder,
|
||||
isDark ? styles.transactionItemBorderDark : styles.transactionItemBorderLight,
|
||||
]}
|
||||
>
|
||||
<View style={styles.transactionHeader}>
|
||||
<Text
|
||||
style={[styles.transactionType, { color: isDark ? '#f9fafb' : '#000000' }]}
|
||||
>
|
||||
{getTransactionTypeLabel(transaction.transaction_type)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.transactionDate, { color: isDark ? '#f9fafb' : '#000000' }]}
|
||||
>
|
||||
{formatDate(transaction.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.transactionDetails}>
|
||||
<Text
|
||||
style={[
|
||||
styles.transactionAmount,
|
||||
{
|
||||
color: getTransactionColor(
|
||||
transaction.transaction_type,
|
||||
transaction.amount
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{transaction.amount.toLocaleString()} Tokens
|
||||
</Text>
|
||||
|
||||
{transaction.model_used && (
|
||||
<Text
|
||||
style={[
|
||||
styles.transactionModel,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
]}
|
||||
>
|
||||
Modell: {transaction.model_used}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{transaction.total_tokens && (
|
||||
<Text
|
||||
style={[
|
||||
styles.transactionTokens,
|
||||
{ color: isDark ? '#f9fafb' : '#000000' },
|
||||
]}
|
||||
>
|
||||
{transaction.prompt_tokens?.toLocaleString() || 0} Input +{' '}
|
||||
{transaction.completion_tokens?.toLocaleString() || 0} Output ={' '}
|
||||
{transaction.total_tokens.toLocaleString()} Tokens
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={[styles.emptyText, isDark ? styles.textDark : styles.textLight]}>
|
||||
Keine Transaktionen gefunden
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{transactions.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.linkButton]}
|
||||
onPress={() => {
|
||||
// Hier könnte eine Seite mit allen Transaktionen angezeigt werden
|
||||
alert('Vollständige Transaktionshistorie wird bald implementiert!');
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.linkButtonText, { color: isDark ? '#818cf8' : '#4f46e5' }]}>
|
||||
Alle Transaktionen anzeigen
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={isDark ? '#818cf8' : '#4f46e5'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Token Store Modal */}
|
||||
<Modal
|
||||
visible={storeVisible}
|
||||
animationType="slide"
|
||||
transparent={false}
|
||||
onRequestClose={() => setStoreVisible(false)}
|
||||
>
|
||||
<TokenStore
|
||||
onClose={() => setStoreVisible(false)}
|
||||
onPurchaseComplete={() => {
|
||||
setStoreVisible(false);
|
||||
// Aktualisiere die Daten nach dem Kauf
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
setTokenBalance(balance);
|
||||
|
||||
const transactionData = await getTokenTransactions(userId, 20);
|
||||
setTransactions(transactionData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Daten:', error);
|
||||
}
|
||||
};
|
||||
|
||||
refreshData();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: '#111827',
|
||||
},
|
||||
containerLight: {
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollViewContent: {
|
||||
padding: 16,
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
cardDark: {
|
||||
backgroundColor: '#1f2937',
|
||||
},
|
||||
cardLight: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
textDark: {
|
||||
color: '#f9fafb',
|
||||
},
|
||||
textLight: {
|
||||
color: '#000000',
|
||||
},
|
||||
balanceText: {
|
||||
fontSize: 36,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDark: {
|
||||
backgroundColor: '#4f46e5',
|
||||
},
|
||||
buttonLight: {
|
||||
backgroundColor: '#4f46e5',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonTextDark: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
buttonTextLight: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
timeframeSelector: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
timeframeButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
timeframeButtonDark: {
|
||||
backgroundColor: '#374151',
|
||||
},
|
||||
timeframeButtonLight: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
},
|
||||
timeframeButtonActive: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
timeframeButtonActiveDark: {
|
||||
borderColor: '#818cf8',
|
||||
backgroundColor: '#312e81',
|
||||
},
|
||||
timeframeButtonActiveLight: {
|
||||
borderColor: '#4f46e5',
|
||||
backgroundColor: '#e0e7ff',
|
||||
},
|
||||
timeframeButtonText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
timeframeButtonTextActive: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
statsContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
transactionItem: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
transactionItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
transactionItemBorderDark: {
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
transactionItemBorderLight: {
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
transactionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 4,
|
||||
},
|
||||
transactionType: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
transactionDate: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
transactionDetails: {
|
||||
marginTop: 4,
|
||||
},
|
||||
transactionAmount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
transactionModel: {
|
||||
fontSize: 12,
|
||||
opacity: 0.8,
|
||||
},
|
||||
transactionTokens: {
|
||||
fontSize: 12,
|
||||
opacity: 0.8,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
padding: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
linkButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
linkButtonText: {
|
||||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
},
|
||||
linkButtonTextDark: {
|
||||
color: '#818cf8',
|
||||
},
|
||||
linkButtonTextLight: {
|
||||
color: '#4f46e5',
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue