mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 10:26:41 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
189
apps-archived/reader/apps/mobile/components/ActionMenu.tsx
Normal file
189
apps-archived/reader/apps/mobile/components/ActionMenu.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Platform,
|
||||
ActionSheetIOS,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface ActionMenuOption {
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
options: ActionMenuOption[];
|
||||
onSelect: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ActionMenu({ options, onSelect, children, title, message }: ActionMenuProps) {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const { colors } = useTheme();
|
||||
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
|
||||
const showActionSheet = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const optionTitles = options.map((opt) => opt.title);
|
||||
const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
|
||||
const disabledButtonIndices = options
|
||||
.map((opt, idx) => (opt.disabled ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: [...optionTitles, 'Abbrechen'],
|
||||
cancelButtonIndex: optionTitles.length,
|
||||
destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
|
||||
disabledButtonIndices,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex !== optionTitles.length) {
|
||||
onSelect(buttonIndex);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (index: number) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => onSelect(index), 100);
|
||||
};
|
||||
|
||||
const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleSelect(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-4`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={22}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-lg ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: showActionSheet,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
|
||||
{Platform.OS !== 'ios' && (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={styles.backdrop} />
|
||||
|
||||
<View style={styles.container}>
|
||||
<View className={`rounded-t-2xl ${colors.surface}`} style={styles.menu}>
|
||||
{(title || message) && (
|
||||
<View className={`border-b px-4 py-3 ${colors.border}`}>
|
||||
{title && (
|
||||
<Text className={`text-center font-semibold ${colors.text}`}>{title}</Text>
|
||||
)}
|
||||
{message && (
|
||||
<Text className={`mt-1 text-center text-sm ${colors.textSecondary}`}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={options}
|
||||
renderItem={renderOption}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => <View className={`h-px ${colors.border}`} />}
|
||||
/>
|
||||
|
||||
<View className={`border-t ${colors.border}`}>
|
||||
<Pressable
|
||||
onPress={() => setVisible(false)}
|
||||
className="py-4"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Text className="text-center text-lg font-medium text-blue-600">Abbrechen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
menu: {
|
||||
maxHeight: '80%',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
android: {
|
||||
elevation: 16,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
489
apps-archived/reader/apps/mobile/components/AudioPlayer.tsx
Normal file
489
apps-archived/reader/apps/mobile/components/AudioPlayer.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAudio } from '~/hooks/useAudio';
|
||||
import { Text as TextType, AudioVersion } from '~/types/database';
|
||||
import { useStore } from '~/store/store';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { Dropdown } from '~/components/dropdown';
|
||||
import {
|
||||
Voice,
|
||||
ALL_VOICES,
|
||||
getVoiceById,
|
||||
GERMAN_VOICES,
|
||||
PROVIDER_LABELS,
|
||||
QUALITY_LABELS,
|
||||
} from '~/constants/voices';
|
||||
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
text: TextType;
|
||||
onAudioGenerated?: () => void;
|
||||
}
|
||||
|
||||
export const AudioPlayer: React.FC<AudioPlayerProps> = ({ text, onAudioGenerated }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showSpeedControl, setShowSpeedControl] = useState(false);
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const progressBarRef = useRef<View>(null);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Use useMemo to prevent re-migration on every render
|
||||
const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
|
||||
const audioVersions = migratedData.audioVersions || [];
|
||||
const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
|
||||
|
||||
// Initialize selectedVersionId with current version
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string>(currentVersion?.id || '');
|
||||
|
||||
// Initialize selected voice
|
||||
useEffect(() => {
|
||||
setSelectedVoice(settings.voice);
|
||||
}, [settings.voice]);
|
||||
|
||||
const {
|
||||
audioState,
|
||||
generationProgress,
|
||||
generateAudio,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
resumeAudio,
|
||||
stopAudio,
|
||||
seekTo,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
setPlaybackSpeed,
|
||||
clearCache,
|
||||
} = useAudio();
|
||||
|
||||
// Pulsating animation for loading state
|
||||
useEffect(() => {
|
||||
if (audioState.isLoading) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.2,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
}, [audioState.isLoading, pulseAnim]);
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
|
||||
|
||||
onAudioGenerated?.();
|
||||
|
||||
Alert.alert(
|
||||
'Audio generiert!',
|
||||
'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceChange = (newVoice: string) => {
|
||||
setSelectedVoice(newVoice);
|
||||
// Update the global settings
|
||||
updateSettings({ voice: newVoice });
|
||||
};
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
if (!selectedVersion?.chunks) return;
|
||||
|
||||
try {
|
||||
if (audioState.isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Play directly from Supabase Storage
|
||||
await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Wiedergabe-Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await stopAudio();
|
||||
};
|
||||
|
||||
const formatTime = (milliseconds: number): string => {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const handleSpeedChange = async (speed: number) => {
|
||||
await setPlaybackSpeed(speed);
|
||||
setShowSpeedControl(false);
|
||||
};
|
||||
|
||||
// Use duration from audio state if available, otherwise calculate from chunks
|
||||
const totalDuration =
|
||||
audioState.duration ||
|
||||
(selectedVersion?.chunks
|
||||
? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
|
||||
: 0);
|
||||
|
||||
// Handle progress bar press
|
||||
const handleProgressPress = async (event: any) => {
|
||||
if (progressBarRef.current && totalDuration > 0) {
|
||||
progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
|
||||
const touchX = event.nativeEvent.pageX - pageX;
|
||||
const progress = Math.max(0, Math.min(1, touchX / width));
|
||||
const newPosition = progress * totalDuration;
|
||||
|
||||
// If audio hasn't been started yet, start it at the desired position
|
||||
if (!audioState.sound) {
|
||||
await playAudio(text.id, text.data.audio!.chunks, newPosition);
|
||||
} else {
|
||||
await seekTo(newPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get the selected audio version
|
||||
const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
|
||||
const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
|
||||
|
||||
return (
|
||||
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
|
||||
{/* Voice selection and generate button - always visible */}
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
|
||||
<Dropdown
|
||||
options={[]}
|
||||
value={selectedVoice}
|
||||
onValueChange={handleVoiceChange}
|
||||
placeholder="Wähle eine Stimme"
|
||||
disabled={isGenerating}
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={handleGenerateAudio}
|
||||
disabled={isGenerating}
|
||||
className={`mt-3 rounded-lg px-4 py-2.5 ${isGenerating ? 'bg-gray-400' : colors.primary}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{generationProgress?.currentChunk || 'Generiere Audio...'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<Ionicons name="volume-high" size={20} color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{generationProgress && (
|
||||
<View className="mt-2">
|
||||
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
|
||||
<View
|
||||
className={`h-1.5 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
|
||||
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Audio versions - only shown when audio exists */}
|
||||
{audioVersions.length > 0 && (
|
||||
<View className="mt-4">
|
||||
<Pressable
|
||||
onPress={() => setShowVersions(!showVersions)}
|
||||
className="flex-row items-center justify-between"
|
||||
>
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
|
||||
Audio-Versionen ({audioVersions.length})
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showVersions ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color="#71717a"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showVersions && (
|
||||
<ScrollView className="mt-2 max-h-40">
|
||||
{audioVersions.map((version) => {
|
||||
const voice = getVoiceById(version.settings.voice);
|
||||
const isActive = version.id === selectedVersionId;
|
||||
const date = new Date(version.createdAt);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={version.id}
|
||||
onPress={() => setSelectedVersionId(version.id)}
|
||||
className={`mb-2 rounded-lg p-3 ${
|
||||
isActive ? 'bg-blue-600' : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
|
||||
{date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}
|
||||
>
|
||||
{voice?.label || version.settings.voice} • {version.settings.speed}x
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
|
||||
<Ionicons
|
||||
name={isActive ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={isActive ? 'white' : '#71717a'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio player - only shown when audio exists */}
|
||||
{hasAudio && (
|
||||
<View className="mt-4 border-t border-zinc-800 pt-3">
|
||||
{/* </View> closing tag moved to end */}
|
||||
{/* Progress bar and time info - full width */}
|
||||
<View className="mb-3">
|
||||
{/* Progress Bar with touch gestures */}
|
||||
<Pressable onPress={handleProgressPress} className="py-2">
|
||||
<View
|
||||
ref={progressBarRef}
|
||||
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}
|
||||
>
|
||||
<View
|
||||
className={`h-2 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width:
|
||||
totalDuration > 0
|
||||
? `${(audioState.currentPosition / totalDuration) * 100}%`
|
||||
: '0%',
|
||||
}}
|
||||
/>
|
||||
{/* Scrubber indicator */}
|
||||
{totalDuration > 0 && (
|
||||
<View
|
||||
className="absolute top-0 h-2"
|
||||
style={{
|
||||
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
|
||||
style={{ marginTop: -2, marginLeft: -6 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Time display */}
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>
|
||||
{formatTime(audioState.currentPosition)}
|
||||
</Text>
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View className="flex-row items-center justify-center">
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={handleStop}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
|
||||
>
|
||||
<Ionicons name="stop" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
|
||||
{/* Backward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekBackward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}
|
||||
>
|
||||
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View
|
||||
className="absolute -bottom-1 -left-1"
|
||||
style={{ transform: [{ scaleX: -1 }] }}
|
||||
>
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.primary} mx-2 p-2.5`}
|
||||
>
|
||||
{audioState.isLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={audioState.isPlaying ? 'pause' : 'play'}
|
||||
size={20}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{/* Forward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekForward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
|
||||
>
|
||||
<View className="relative">
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View className="absolute -bottom-1 -right-1">
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Speed control button */}
|
||||
<Pressable
|
||||
onPress={() => setShowSpeedControl(!showSpeedControl)}
|
||||
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}
|
||||
>
|
||||
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
|
||||
{audioState.playbackRate}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Speed options dropdown */}
|
||||
{showSpeedControl && (
|
||||
<View className="mt-2 flex-row justify-center">
|
||||
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
|
||||
{speedOptions.map((speed) => (
|
||||
<Pressable
|
||||
key={speed}
|
||||
onPress={() => handleSpeedChange(speed)}
|
||||
className={`mx-1 rounded px-3 py-1 ${
|
||||
audioState.playbackRate === speed ? colors.primary : ''
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
|
||||
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{speed}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
207
apps-archived/reader/apps/mobile/components/Button.tsx
Normal file
207
apps-archived/reader/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React from 'react';
|
||||
import { Pressable, PressableProps, ActivityIndicator, View } from 'react-native';
|
||||
import { Icon, IconName } from './Icon';
|
||||
import { Text } from './Text';
|
||||
|
||||
export type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface ButtonProps extends Omit<PressableProps, 'children'> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: IconName;
|
||||
iconPosition?: 'left' | 'right';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Get variant styles
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
case 'secondary':
|
||||
return 'bg-gray-600 active:bg-gray-700';
|
||||
case 'outline':
|
||||
return 'border border-gray-300 bg-white active:bg-gray-50';
|
||||
case 'ghost':
|
||||
return 'bg-transparent active:bg-gray-100';
|
||||
case 'link':
|
||||
return 'bg-transparent';
|
||||
case 'destructive':
|
||||
return 'bg-red-600 active:bg-red-700';
|
||||
case 'success':
|
||||
return 'bg-green-600 active:bg-green-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-600 active:bg-yellow-700';
|
||||
default:
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
// Get size styles
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-2 py-1';
|
||||
case 'sm':
|
||||
return 'px-3 py-2';
|
||||
case 'md':
|
||||
return 'px-4 py-3';
|
||||
case 'lg':
|
||||
return 'px-6 py-4';
|
||||
case 'xl':
|
||||
return 'px-8 py-5';
|
||||
default:
|
||||
return 'px-4 py-3';
|
||||
}
|
||||
};
|
||||
|
||||
// Get text color
|
||||
const getTextColor = () => {
|
||||
if (isDisabled) return 'muted';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return 'white';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return 'gray';
|
||||
case 'link':
|
||||
return 'primary';
|
||||
default:
|
||||
return 'white';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon color
|
||||
const getIconColor = () => {
|
||||
if (isDisabled) return '#9CA3AF';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return '#FFFFFF';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return '#6B7280';
|
||||
case 'link':
|
||||
return '#2563EB';
|
||||
default:
|
||||
return '#FFFFFF';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon size
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 14;
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
return 18;
|
||||
case 'lg':
|
||||
return 20;
|
||||
case 'xl':
|
||||
return 24;
|
||||
default:
|
||||
return 18;
|
||||
}
|
||||
};
|
||||
|
||||
// Get text variant
|
||||
const getTextVariant = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
case 'sm':
|
||||
return 'buttonSmall';
|
||||
default:
|
||||
return 'button';
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <ActivityIndicator size="small" color={getIconColor()} />;
|
||||
}
|
||||
|
||||
const iconElement = icon ? (
|
||||
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
|
||||
) : null;
|
||||
|
||||
const textElement = children ? (
|
||||
<Text variant={getTextVariant()} color={getTextColor()} align="center">
|
||||
{children}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
if (!icon && !children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (icon && !children) {
|
||||
return iconElement;
|
||||
}
|
||||
|
||||
if (!icon && children) {
|
||||
return textElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{iconPosition === 'left' && iconElement}
|
||||
{textElement}
|
||||
{iconPosition === 'right' && iconElement}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonClasses = [
|
||||
'rounded-lg items-center justify-center',
|
||||
getSizeClasses(),
|
||||
getVariantClasses(),
|
||||
fullWidth ? 'w-full' : '',
|
||||
isDisabled ? 'opacity-50' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
|
||||
{renderContent()}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
159
apps-archived/reader/apps/mobile/components/ContextMenu.tsx
Normal file
159
apps-archived/reader/apps/mobile/components/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface ContextMenuAction {
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
actions: ContextMenuAction[];
|
||||
onPress: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function ContextMenu({ actions, onPress, children }: ContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const childRef = useRef<View>(null);
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleLongPress = () => {
|
||||
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
const menuHeight = actions.length * 50 + 20; // Approximate menu height
|
||||
|
||||
// Position menu above or below the pressed item based on available space
|
||||
const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
|
||||
|
||||
setMenuPosition({ x: pageX, y: posY });
|
||||
setVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionPress = (index: number) => {
|
||||
setVisible(false);
|
||||
// Small delay to allow modal to close before action
|
||||
setTimeout(() => onPress(index), 100);
|
||||
};
|
||||
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
|
||||
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleActionPress(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-3 ${
|
||||
index < actions.length - 1 ? `border-b ${colors.border}` : ''
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={20}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View ref={childRef} collapsable={false}>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: handleLongPress,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.menu,
|
||||
{
|
||||
top: menuPosition.y,
|
||||
left: 20,
|
||||
right: 20,
|
||||
maxWidth: 300,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
]}
|
||||
className={`rounded-lg shadow-lg ${colors.surface}`}
|
||||
>
|
||||
<FlatList
|
||||
data={actions}
|
||||
renderItem={renderAction}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
menu: {
|
||||
position: 'absolute',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
android: {
|
||||
elevation: 8,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { Pressable, Text, ActivityIndicator, ViewStyle } from 'react-native';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
onPress: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function FloatingActionButton({
|
||||
onPress,
|
||||
icon,
|
||||
label,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
style,
|
||||
}: FloatingActionButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={style}
|
||||
className={`flex-row items-center rounded-full px-4 py-3 shadow-lg ${
|
||||
disabled || loading ? 'bg-gray-400' : colors.primary
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<>
|
||||
<Text className="mr-2 text-lg text-white">{icon}</Text>
|
||||
<Text className="font-medium text-white">{label}</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
92
apps-archived/reader/apps/mobile/components/Header.tsx
Normal file
92
apps-archived/reader/apps/mobile/components/Header.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, Platform, StatusBar } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
showBackButton?: boolean;
|
||||
rightComponent?: React.ReactNode;
|
||||
onBackPress?: () => void;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showBackButton = true,
|
||||
rightComponent,
|
||||
onBackPress,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDark, colors } = useTheme();
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
// Use theme colors if not explicitly provided
|
||||
const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
|
||||
const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
|
||||
const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: headerBackgroundColor,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: borderColor,
|
||||
}}
|
||||
>
|
||||
<StatusBar
|
||||
barStyle={isDark ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={headerBackgroundColor}
|
||||
/>
|
||||
|
||||
<View className="min-h-[44px] flex-row items-center justify-between">
|
||||
{/* Left side - Back button */}
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{showBackButton && (
|
||||
<Pressable
|
||||
onPress={handleBackPress}
|
||||
className="-ml-2 mr-3 rounded-full p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Icon name="arrow-back" size={24} color={headerTextColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Center - Title */}
|
||||
<View className="flex-2 items-center">
|
||||
{title && (
|
||||
<Text
|
||||
variant="h4"
|
||||
color={headerTextColor === '#000000' ? 'black' : 'white'}
|
||||
className="text-center font-semibold"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Custom component */}
|
||||
<View className="flex-1 flex-row items-center justify-end">{rightComponent}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
128
apps-archived/reader/apps/mobile/components/Icon.tsx
Normal file
128
apps-archived/reader/apps/mobile/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export type IconName =
|
||||
| 'add'
|
||||
| 'delete'
|
||||
| 'edit'
|
||||
| 'save'
|
||||
| 'close'
|
||||
| 'back'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'refresh'
|
||||
| 'settings'
|
||||
| 'logout'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'heart'
|
||||
| 'heart-outline'
|
||||
| 'tag'
|
||||
| 'filter'
|
||||
| 'search'
|
||||
| 'download'
|
||||
| 'share'
|
||||
| 'volume-high'
|
||||
| 'volume-low'
|
||||
| 'volume-mute'
|
||||
| 'fast-forward'
|
||||
| 'rewind'
|
||||
| 'skip-forward'
|
||||
| 'skip-backward'
|
||||
| 'checkmark'
|
||||
| 'close-circle'
|
||||
| 'alert-circle'
|
||||
| 'information-circle'
|
||||
| 'chevron-down'
|
||||
| 'chevron-up'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'arrow-back'
|
||||
| 'arrow-forward'
|
||||
| 'home'
|
||||
| 'library'
|
||||
| 'person'
|
||||
| 'menu'
|
||||
| 'more-horizontal'
|
||||
| 'more-vertical'
|
||||
| 'replay-15'
|
||||
| 'forward-15'
|
||||
| 'play-circle'
|
||||
| 'pause-circle'
|
||||
| 'mic-circle';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconMapping: Record<IconName, keyof typeof Ionicons.glyphMap> = {
|
||||
add: 'add',
|
||||
delete: 'trash',
|
||||
edit: 'pencil',
|
||||
save: 'save',
|
||||
close: 'close',
|
||||
back: 'arrow-back',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
stop: 'stop',
|
||||
refresh: 'refresh',
|
||||
settings: 'settings',
|
||||
logout: 'log-out',
|
||||
eye: 'eye',
|
||||
'eye-off': 'eye-off',
|
||||
heart: 'heart',
|
||||
'heart-outline': 'heart-outline',
|
||||
tag: 'pricetag',
|
||||
filter: 'filter',
|
||||
search: 'search',
|
||||
download: 'download',
|
||||
share: 'share',
|
||||
'volume-high': 'volume-high',
|
||||
'volume-low': 'volume-low',
|
||||
'volume-mute': 'volume-mute',
|
||||
'fast-forward': 'play-forward',
|
||||
rewind: 'play-back',
|
||||
'skip-forward': 'play-skip-forward',
|
||||
'skip-backward': 'play-skip-back',
|
||||
checkmark: 'checkmark',
|
||||
'close-circle': 'close-circle',
|
||||
'alert-circle': 'alert-circle',
|
||||
'information-circle': 'information-circle',
|
||||
'chevron-down': 'chevron-down',
|
||||
'chevron-up': 'chevron-up',
|
||||
'chevron-left': 'chevron-back',
|
||||
'chevron-right': 'chevron-forward',
|
||||
'arrow-back': 'arrow-back',
|
||||
'arrow-forward': 'arrow-forward',
|
||||
home: 'home',
|
||||
library: 'library',
|
||||
person: 'person',
|
||||
menu: 'menu',
|
||||
'more-horizontal': 'ellipsis-horizontal',
|
||||
'more-vertical': 'ellipsis-vertical',
|
||||
'replay-15': 'refresh-circle',
|
||||
'forward-15': 'add-circle',
|
||||
'play-circle': 'play-circle',
|
||||
'pause-circle': 'pause-circle',
|
||||
'mic-circle': 'mic-circle',
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({ name, size = 24, color = '#000000', className }) => {
|
||||
const ionIconName = iconMapping[name];
|
||||
|
||||
if (!ionIconName) {
|
||||
console.warn(`Icon "${name}" not found in iconMapping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={className}>
|
||||
<Ionicons name={ionIconName} size={size} color={color} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useAudio } from '~/hooks/useAudio';
|
||||
import { Text as TextType } from '~/types/database';
|
||||
import { useStore } from '~/store/store';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
|
||||
|
||||
interface MinimalAudioPlayerProps {
|
||||
text: TextType;
|
||||
}
|
||||
|
||||
export const MinimalAudioPlayer: React.FC<MinimalAudioPlayerProps> = ({ text }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { currentTextId } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
|
||||
|
||||
// Check if this text is currently playing
|
||||
const isCurrentText = currentTextId === text.id;
|
||||
const isPlaying = isCurrentText && audioState.isPlaying;
|
||||
const isLoading = isCurrentText && audioState.isLoading;
|
||||
|
||||
// Get audio version
|
||||
const migratedData = migrateAudioData(text.data);
|
||||
const currentVersion = getCurrentAudioVersion(migratedData);
|
||||
const hasAudio = currentVersion && currentVersion.chunks.length > 0;
|
||||
|
||||
// Stop audio when component unmounts or text changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isCurrentText) {
|
||||
stopAudio();
|
||||
}
|
||||
};
|
||||
}, [isCurrentText, stopAudio]);
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
if (!hasAudio) {
|
||||
// Generate audio if not available
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const { settings } = useStore.getState();
|
||||
await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (isCurrentText && audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Stop any other playing audio and start this one
|
||||
if (currentTextId && currentTextId !== text.id) {
|
||||
await stopAudio();
|
||||
}
|
||||
await playAudio(text.id, currentVersion.chunks, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={isLoading || isGenerating}
|
||||
className={`rounded-full p-2 ${
|
||||
hasAudio ? colors.surfaceSecondary : colors.surface
|
||||
} active:opacity-70`}
|
||||
>
|
||||
{isLoading || isGenerating ? (
|
||||
<ActivityIndicator size="small" color={colors.tabBarInactive} />
|
||||
) : (
|
||||
<Icon
|
||||
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
|
||||
size={28}
|
||||
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
15
apps-archived/reader/apps/mobile/components/TabBarIcon.tsx
Normal file
15
apps-archived/reader/apps/mobile/components/TabBarIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const TabBarIcon = (props: {
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
}) => {
|
||||
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
55
apps-archived/reader/apps/mobile/components/TagFilter.tsx
Normal file
55
apps-archived/reader/apps/mobile/components/TagFilter.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { View, Text, ScrollView, Pressable } from 'react-native';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
import { useStore } from '~/store/store';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export const TagFilter: React.FC = () => {
|
||||
const { getAllTags } = useTexts();
|
||||
const { selectedTags, toggleTag, clearTags } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const allTags = getAllTags();
|
||||
|
||||
if (allTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`border-b ${colors.border} ${colors.surface} px-4 py-2`}>
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>Tags filtern:</Text>
|
||||
{selectedTags.length > 0 && (
|
||||
<Pressable onPress={clearTags}>
|
||||
<Text className="text-sm text-blue-600">Alle entfernen</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingRight: 16 }}
|
||||
>
|
||||
{allTags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
return (
|
||||
<Pressable
|
||||
key={tag}
|
||||
onPress={() => toggleTag(tag)}
|
||||
className={`mr-2 rounded-full border px-3 py-1 ${
|
||||
isSelected
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: `${colors.borderSecondary} ${colors.surfaceSecondary}`
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-sm ${isSelected ? 'text-blue-800' : colors.textSecondary}`}>
|
||||
{tag}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
158
apps-archived/reader/apps/mobile/components/Text.tsx
Normal file
158
apps-archived/reader/apps/mobile/components/Text.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import React from 'react';
|
||||
import { Text as RNText, TextProps as RNTextProps } from 'react-native';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export type TextVariant =
|
||||
| 'h1'
|
||||
| 'h2'
|
||||
| 'h3'
|
||||
| 'h4'
|
||||
| 'h5'
|
||||
| 'h6'
|
||||
| 'body'
|
||||
| 'bodyLarge'
|
||||
| 'bodySmall'
|
||||
| 'caption'
|
||||
| 'label'
|
||||
| 'labelLarge'
|
||||
| 'labelSmall'
|
||||
| 'button'
|
||||
| 'buttonSmall'
|
||||
| 'overline'
|
||||
| 'subtitle1'
|
||||
| 'subtitle2';
|
||||
|
||||
export type TextColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'accent'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'white'
|
||||
| 'black'
|
||||
| 'gray'
|
||||
| 'muted'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'indigo'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'inherit';
|
||||
|
||||
interface TextComponentProps extends RNTextProps {
|
||||
variant?: TextVariant;
|
||||
color?: TextColor;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, string> = {
|
||||
h1: 'text-4xl font-bold',
|
||||
h2: 'text-3xl font-bold',
|
||||
h3: 'text-2xl font-bold',
|
||||
h4: 'text-xl font-bold',
|
||||
h5: 'text-lg font-bold',
|
||||
h6: 'text-base font-bold',
|
||||
body: 'text-base',
|
||||
bodyLarge: 'text-lg',
|
||||
bodySmall: 'text-sm',
|
||||
caption: 'text-xs',
|
||||
label: 'text-sm font-medium',
|
||||
labelLarge: 'text-base font-medium',
|
||||
labelSmall: 'text-xs font-medium',
|
||||
button: 'text-base font-semibold',
|
||||
buttonSmall: 'text-sm font-semibold',
|
||||
overline: 'text-xs font-medium uppercase tracking-wide',
|
||||
subtitle1: 'text-base font-medium',
|
||||
subtitle2: 'text-sm font-medium',
|
||||
};
|
||||
|
||||
const colorStyles: Record<TextColor, string> = {
|
||||
primary: 'text-blue-600',
|
||||
secondary: 'text-gray-600',
|
||||
accent: 'text-purple-600',
|
||||
error: 'text-red-600',
|
||||
warning: 'text-yellow-600',
|
||||
success: 'text-green-600',
|
||||
info: 'text-blue-500',
|
||||
white: 'text-white',
|
||||
black: 'text-black',
|
||||
gray: 'text-gray-500',
|
||||
muted: 'text-gray-400',
|
||||
red: 'text-red-600',
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
purple: 'text-purple-600',
|
||||
pink: 'text-pink-600',
|
||||
indigo: 'text-indigo-600',
|
||||
cyan: 'text-cyan-600',
|
||||
orange: 'text-orange-600',
|
||||
};
|
||||
|
||||
const weightStyles: Record<string, string> = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const alignStyles: Record<string, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
export const Text: React.FC<TextComponentProps> = ({
|
||||
variant = 'body',
|
||||
color = 'inherit',
|
||||
weight,
|
||||
align,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Map semantic colors to theme colors
|
||||
const getThemeColor = (textColor: TextColor): string => {
|
||||
switch (textColor) {
|
||||
case 'inherit':
|
||||
case 'primary':
|
||||
return colors.text;
|
||||
case 'secondary':
|
||||
return colors.textSecondary;
|
||||
case 'tertiary':
|
||||
case 'muted':
|
||||
return colors.textTertiary;
|
||||
default:
|
||||
return colorStyles[textColor] || colors.text;
|
||||
}
|
||||
};
|
||||
|
||||
const variantClass = variantStyles[variant];
|
||||
const colorClass = getThemeColor(color);
|
||||
const weightClass = weight ? weightStyles[weight] : '';
|
||||
const alignClass = align ? alignStyles[align] : '';
|
||||
|
||||
const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<RNText className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
95
apps-archived/reader/apps/mobile/components/TextListItem.tsx
Normal file
95
apps-archived/reader/apps/mobile/components/TextListItem.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { ActionMenu } from '~/components/ActionMenu';
|
||||
import { MinimalAudioPlayer } from '~/components/MinimalAudioPlayer';
|
||||
import { Text as TextType } from '~/types/database';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface TextListItemProps {
|
||||
item: TextType;
|
||||
onShare: (text: TextType) => void;
|
||||
onDelete: (textId: string, title: string) => void;
|
||||
formatDate: (dateString: string) => string;
|
||||
getAudioDuration: (item: TextType) => string | null;
|
||||
}
|
||||
|
||||
export const TextListItem: React.FC<TextListItemProps> = ({
|
||||
item,
|
||||
onShare,
|
||||
onDelete,
|
||||
formatDate,
|
||||
getAudioDuration,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleMenuSelect = (index: number) => {
|
||||
switch (index) {
|
||||
case 0: // Öffnen
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 1: // Teilen
|
||||
onShare(item);
|
||||
break;
|
||||
case 2: // Tags bearbeiten
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 3: // Löschen
|
||||
onDelete(item.id, item.title);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
options={[
|
||||
{ title: 'Öffnen', systemIcon: 'doc.text' },
|
||||
{ title: 'Teilen', systemIcon: 'square.and.arrow.up' },
|
||||
{ title: 'Tags bearbeiten', systemIcon: 'tag' },
|
||||
{ title: 'Löschen', systemIcon: 'trash', destructive: true },
|
||||
]}
|
||||
onSelect={handleMenuSelect}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.push(`/text/${item.id}`)}
|
||||
className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}
|
||||
>
|
||||
{/* Header with title and date/duration */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<Text className={`mr-2 flex-1 text-lg font-semibold ${colors.text}`} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{formatDate(item.updated_at)}</Text>
|
||||
{getAudioDuration(item) && (
|
||||
<>
|
||||
<Text className={`mx-1 text-sm ${colors.textTertiary}`}>•</Text>
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{getAudioDuration(item)}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content preview */}
|
||||
<Text className={`mb-3 ${colors.textSecondary}`} numberOfLines={2}>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
{/* Footer with tags and audio player */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
{item.data.tags?.map((tag, index) => (
|
||||
<View key={index} className={`mr-2 rounded-full ${colors.primaryLight} px-2 py-1`}>
|
||||
<Text className="text-xs text-blue-800">{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
<MinimalAudioPlayer text={item} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ActionMenu>
|
||||
);
|
||||
};
|
||||
144
apps-archived/reader/apps/mobile/components/dropdown.tsx
Normal file
144
apps-archived/reader/apps/mobile/components/dropdown.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Modal, ScrollView, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface DropdownOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropdownGroup {
|
||||
title: string;
|
||||
options: DropdownOption[];
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
options: DropdownOption[];
|
||||
groups?: DropdownGroup[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
options,
|
||||
groups,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = 'Select an option',
|
||||
disabled = false,
|
||||
title = 'Select Option',
|
||||
}: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Find selected option from either flat options or groups
|
||||
const allOptions = groups ? groups.flatMap((g) => g.options) : options;
|
||||
const selectedOption = allOptions.find((opt) => opt.value === value);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => !disabled && setIsOpen(true)}
|
||||
className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text
|
||||
className={`flex-1 ${selectedOption ? colors.text : colors.textSecondary}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedOption?.label || placeholder}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isOpen ? 'chevron-up' : 'chevron-down'}
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View className="flex-1 justify-center px-4">
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}
|
||||
>
|
||||
<View className={`border-b ${colors.border} px-4 py-3`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className={`text-lg font-semibold ${colors.text}`}>{title}</Text>
|
||||
<TouchableOpacity onPress={() => setIsOpen(false)}>
|
||||
<Ionicons name="close-circle" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView className="px-2 py-2" showsVerticalScrollIndicator={true}>
|
||||
{groups
|
||||
? // Render grouped options
|
||||
groups.map((group, groupIndex) => (
|
||||
<View key={group.title} className={groupIndex > 0 ? 'mt-4' : ''}>
|
||||
<Text className={`mx-2 mb-2 text-sm font-bold ${colors.textSecondary}`}>
|
||||
{group.title}
|
||||
</Text>
|
||||
{group.options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
: // Render flat options
|
||||
options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue