feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
714
apps/mobile-rn/src/screens/DictionaryScreen.tsx
Normal file
714
apps/mobile-rn/src/screens/DictionaryScreen.tsx
Normal file
|
|
@ -0,0 +1,714 @@
|
|||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useI18n, type TranslationKey } from '@d3ro/i18n'
|
||||
import type { DictionaryEntry } from '@d3ro/api-client'
|
||||
import { useAuth } from '../lib/auth-context'
|
||||
import { useMobilePreferences } from '../lib/preferences-context'
|
||||
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
|
||||
import {
|
||||
createDictionaryEntry,
|
||||
deleteDictionaryEntry,
|
||||
DictionaryServiceError,
|
||||
listDictionaryPage,
|
||||
mergeDictionaryEntries,
|
||||
normalizeDictionaryDraft,
|
||||
subscribeToDictionary,
|
||||
updateDictionaryEntry,
|
||||
type DictionaryCategory,
|
||||
type DictionaryCursor,
|
||||
type DictionaryServiceErrorCode,
|
||||
} from '../features/dictionary/dictionary-service'
|
||||
|
||||
type CategoryFilter = DictionaryCategory | 'all'
|
||||
|
||||
interface EditorState {
|
||||
entry: DictionaryEntry | null
|
||||
word: string
|
||||
pronunciation: string
|
||||
category: DictionaryCategory
|
||||
}
|
||||
|
||||
const EMPTY_EDITOR: EditorState = {
|
||||
entry: null,
|
||||
word: '',
|
||||
pronunciation: '',
|
||||
category: 'user',
|
||||
}
|
||||
|
||||
const CATEGORY_FILTERS: readonly CategoryFilter[] = [
|
||||
'all',
|
||||
'user',
|
||||
'technical',
|
||||
'auto',
|
||||
]
|
||||
|
||||
function errorKey(code: DictionaryServiceErrorCode): TranslationKey {
|
||||
switch (code) {
|
||||
case 'auth': return 'mobile.dictionary.error.auth'
|
||||
case 'conflict': return 'mobile.dictionary.error.conflict'
|
||||
case 'duplicate': return 'mobile.dictionary.error.duplicate'
|
||||
case 'network': return 'mobile.dictionary.error.network'
|
||||
case 'not-found': return 'mobile.dictionary.error.notFound'
|
||||
case 'validation': return 'mobile.dictionary.error.validation'
|
||||
default: return 'mobile.dictionary.error.server'
|
||||
}
|
||||
}
|
||||
|
||||
function categoryKey(category: CategoryFilter): TranslationKey {
|
||||
switch (category) {
|
||||
case 'all': return 'mobile.dictionary.category.all'
|
||||
case 'user': return 'mobile.dictionary.category.user'
|
||||
case 'technical': return 'mobile.dictionary.category.technical'
|
||||
case 'auto': return 'mobile.dictionary.category.auto'
|
||||
}
|
||||
}
|
||||
|
||||
export default function DictionaryScreen(): React.ReactElement {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useI18n()
|
||||
const { user } = useAuth()
|
||||
const isFocused = useIsFocused()
|
||||
const { palette } = useMobilePreferences()
|
||||
const styles = useMemo(() => createStyles(palette), [palette])
|
||||
const requestGeneration = useRef(0)
|
||||
const realtimeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [entries, setEntries] = useState<DictionaryEntry[]>([])
|
||||
const [cursor, setCursor] = useState<DictionaryCursor | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<DictionaryServiceErrorCode | null>(null)
|
||||
const [realtimeConnected, setRealtimeConnected] = useState(true)
|
||||
const [editor, setEditor] = useState<EditorState | null>(null)
|
||||
const [editorError, setEditorError] = useState<DictionaryServiceErrorCode | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 350)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const load = useCallback(async (
|
||||
reset: boolean,
|
||||
pullToRefresh = false,
|
||||
): Promise<void> => {
|
||||
const userId = user?.id
|
||||
if (userId === undefined) {
|
||||
setLoading(false)
|
||||
setError('auth')
|
||||
return
|
||||
}
|
||||
if (!reset && cursor === null) return
|
||||
|
||||
const generation = reset ? ++requestGeneration.current : requestGeneration.current
|
||||
if (pullToRefresh) setRefreshing(true)
|
||||
else if (reset) setLoading(true)
|
||||
else setLoadingMore(true)
|
||||
|
||||
try {
|
||||
const page = await listDictionaryPage({
|
||||
userId,
|
||||
search: debouncedSearch,
|
||||
category,
|
||||
cursor: reset ? null : cursor,
|
||||
})
|
||||
if (generation !== requestGeneration.current) return
|
||||
setEntries((current) => reset
|
||||
? page.entries
|
||||
: mergeDictionaryEntries(current, page.entries))
|
||||
setCursor(page.nextCursor)
|
||||
if (reset) setTotal(page.total)
|
||||
setError(null)
|
||||
} catch (candidate) {
|
||||
if (generation !== requestGeneration.current) return
|
||||
const serviceError = candidate instanceof DictionaryServiceError
|
||||
? candidate
|
||||
: new DictionaryServiceError('server', 'Dictionary request failed')
|
||||
setError(serviceError.code)
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [category, cursor, debouncedSearch, user?.id])
|
||||
|
||||
const loadFirstPageRef = useRef<() => void>(() => undefined)
|
||||
const lastFocusReloadKey = useRef<string | null>(null)
|
||||
loadFirstPageRef.current = () => {
|
||||
void load(true)
|
||||
}
|
||||
|
||||
const focusReloadKey = `${user?.id ?? 'anonymous'}:${category}:${debouncedSearch}`
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
lastFocusReloadKey.current = null
|
||||
return
|
||||
}
|
||||
if (lastFocusReloadKey.current === focusReloadKey) return
|
||||
lastFocusReloadKey.current = focusReloadKey
|
||||
loadFirstPageRef.current()
|
||||
}, [focusReloadKey, isFocused])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id === undefined) return undefined
|
||||
const subscription = subscribeToDictionary(
|
||||
user.id,
|
||||
() => {
|
||||
if (realtimeTimer.current !== null) clearTimeout(realtimeTimer.current)
|
||||
realtimeTimer.current = setTimeout(() => loadFirstPageRef.current(), 250)
|
||||
},
|
||||
(status) => setRealtimeConnected(status === 'SUBSCRIBED'),
|
||||
)
|
||||
return () => {
|
||||
if (realtimeTimer.current !== null) {
|
||||
clearTimeout(realtimeTimer.current)
|
||||
realtimeTimer.current = null
|
||||
}
|
||||
void subscription.unsubscribe()
|
||||
}
|
||||
}, [user?.id])
|
||||
|
||||
const openAdd = useCallback((): void => {
|
||||
setEditor({ ...EMPTY_EDITOR })
|
||||
setEditorError(null)
|
||||
}, [])
|
||||
|
||||
const openEdit = useCallback((entry: DictionaryEntry): void => {
|
||||
setEditor({
|
||||
entry,
|
||||
word: entry.word,
|
||||
pronunciation: entry.pronunciation ?? '',
|
||||
category: entry.category,
|
||||
})
|
||||
setEditorError(null)
|
||||
}, [])
|
||||
|
||||
const closeEditor = useCallback((): void => {
|
||||
if (saving) return
|
||||
setEditor(null)
|
||||
setEditorError(null)
|
||||
}, [saving])
|
||||
|
||||
const saveEditor = useCallback(async (): Promise<void> => {
|
||||
if (editor === null || user?.id === undefined || saving) return
|
||||
try {
|
||||
normalizeDictionaryDraft(editor)
|
||||
} catch (candidate) {
|
||||
const serviceError = candidate instanceof DictionaryServiceError
|
||||
? candidate
|
||||
: new DictionaryServiceError('validation', 'Dictionary values are invalid')
|
||||
setEditorError(serviceError.code)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setEditorError(null)
|
||||
try {
|
||||
if (editor.entry === null) {
|
||||
await createDictionaryEntry(user.id, editor)
|
||||
} else {
|
||||
await updateDictionaryEntry(user.id, editor.entry, editor)
|
||||
}
|
||||
setEditor(null)
|
||||
await load(true)
|
||||
} catch (candidate) {
|
||||
const serviceError = candidate instanceof DictionaryServiceError
|
||||
? candidate
|
||||
: new DictionaryServiceError('server', 'Dictionary mutation failed')
|
||||
setEditorError(serviceError.code)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [editor, load, saving, user?.id])
|
||||
|
||||
const removeEntry = useCallback((entry: DictionaryEntry): void => {
|
||||
if (deletingId !== null || user?.id === undefined) return
|
||||
Alert.alert(
|
||||
t('mobile.dictionary.deleteTitle'),
|
||||
t('mobile.dictionary.deleteBody', { word: entry.word }),
|
||||
[
|
||||
{ text: t('mobile.dictionary.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('mobile.dictionary.delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setDeletingId(entry.id)
|
||||
void deleteDictionaryEntry(user.id, entry)
|
||||
.then(() => load(true))
|
||||
.catch((candidate: unknown) => {
|
||||
const serviceError = candidate instanceof DictionaryServiceError
|
||||
? candidate
|
||||
: new DictionaryServiceError('server', 'Dictionary delete failed')
|
||||
setError(serviceError.code)
|
||||
})
|
||||
.finally(() => setDeletingId(null))
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}, [deletingId, load, t, user?.id])
|
||||
|
||||
const renderEntry = useCallback(({ item }: { item: DictionaryEntry }): React.ReactElement => (
|
||||
<ThemeCard style={styles.entryCard} testID={`dictionary-entry-${item.id}`}>
|
||||
<View style={styles.entryHeader}>
|
||||
<View style={styles.entryText}>
|
||||
<ThemeText variant="title" accessibilityRole="header">{item.word}</ThemeText>
|
||||
{item.pronunciation !== null && (
|
||||
<ThemeText color="accent">[{item.pronunciation}]</ThemeText>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.categoryBadge}>
|
||||
<ThemeText variant="label" color="muted">{t(categoryKey(item.category))}</ThemeText>
|
||||
</View>
|
||||
</View>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.dictionary.usage', { count: item.usage_count })}
|
||||
</ThemeText>
|
||||
<View style={styles.entryActions}>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.edit')}
|
||||
variant="secondary"
|
||||
onPress={() => openEdit(item)}
|
||||
style={styles.entryButton}
|
||||
testID={`dictionary-edit-${item.id}`}
|
||||
/>
|
||||
<ThemeButton
|
||||
label={deletingId === item.id
|
||||
? t('mobile.dictionary.deleting')
|
||||
: t('mobile.dictionary.delete')}
|
||||
variant="danger"
|
||||
disabled={deletingId !== null}
|
||||
onPress={() => removeEntry(item)}
|
||||
style={styles.entryButton}
|
||||
testID={`dictionary-delete-${item.id}`}
|
||||
/>
|
||||
</View>
|
||||
</ThemeCard>
|
||||
), [deletingId, openEdit, removeEntry, styles, t])
|
||||
|
||||
const listEmpty = loading ? (
|
||||
<View style={styles.centerState} testID="dictionary-loading">
|
||||
<ActivityIndicator color={palette.accent.main} />
|
||||
<ThemeText color="muted">{t('mobile.dictionary.loading')}</ThemeText>
|
||||
</View>
|
||||
) : error !== null ? (
|
||||
<View style={styles.centerState} testID="dictionary-error">
|
||||
<ThemeText color="danger" accessibilityLiveRegion="assertive">{t(errorKey(error))}</ThemeText>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.retry')}
|
||||
variant="secondary"
|
||||
onPress={() => void load(true)}
|
||||
testID="dictionary-retry"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerState} testID="dictionary-empty">
|
||||
<ThemeText color="muted" style={styles.centerText}>
|
||||
{debouncedSearch.length > 0 || category !== 'all'
|
||||
? t('mobile.dictionary.emptySearch')
|
||||
: t('mobile.dictionary.empty')}
|
||||
</ThemeText>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.add')}
|
||||
onPress={openAdd}
|
||||
testID="dictionary-empty-add"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="dictionary-screen">
|
||||
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
|
||||
<View>
|
||||
<ThemeText variant="title" accessibilityRole="header">{t('mobile.dictionary.title')}</ThemeText>
|
||||
<ThemeText variant="label" color="muted">
|
||||
{t('mobile.dictionary.count', { count: total })}
|
||||
</ThemeText>
|
||||
</View>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.add')}
|
||||
onPress={openAdd}
|
||||
style={styles.addButton}
|
||||
testID="dictionary-add"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!realtimeConnected && (
|
||||
<View style={styles.realtimeBanner} testID="dictionary-realtime-warning">
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.dictionary.realtimeWaiting')}
|
||||
</ThemeText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.searchArea}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.dictionary.search')}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t('mobile.dictionary.search')}
|
||||
placeholderTextColor={palette.text.muted}
|
||||
maxLength={100}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
style={styles.searchInput}
|
||||
testID="dictionary-search"
|
||||
/>
|
||||
<View style={styles.filters} accessibilityRole="tablist">
|
||||
{CATEGORY_FILTERS.map((value) => {
|
||||
const active = category === value
|
||||
return (
|
||||
<Pressable
|
||||
key={value}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => setCategory(value)}
|
||||
style={[styles.filter, active && styles.filterActive]}
|
||||
testID={`dictionary-filter-${value}`}
|
||||
>
|
||||
<ThemeText variant="label" color={active ? 'onAccent' : 'muted'}>
|
||||
{t(categoryKey(value))}
|
||||
</ThemeText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error !== null && entries.length > 0 && (
|
||||
<View style={styles.inlineError} testID="dictionary-inline-error">
|
||||
<ThemeText color="danger" accessibilityLiveRegion="assertive">
|
||||
{t(errorKey(error))}
|
||||
</ThemeText>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.retry')}
|
||||
variant="secondary"
|
||||
onPress={() => void load(true)}
|
||||
style={styles.inlineRetry}
|
||||
testID="dictionary-inline-retry"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={entries}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderEntry}
|
||||
contentContainerStyle={entries.length === 0
|
||||
? styles.emptyList
|
||||
: [styles.listContent, { paddingBottom: Math.max(insets.bottom, 24) }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={(
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void load(true, true)}
|
||||
tintColor={palette.accent.main}
|
||||
colors={[palette.accent.main]}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={listEmpty}
|
||||
ListFooterComponent={loadingMore ? (
|
||||
<View style={styles.loadMore} testID="dictionary-loading-more">
|
||||
<ActivityIndicator color={palette.accent.main} size="small" />
|
||||
<ThemeText color="muted">{t('mobile.dictionary.loadingMore')}</ThemeText>
|
||||
</View>
|
||||
) : null}
|
||||
onEndReached={() => {
|
||||
if (!loading && !loadingMore && cursor !== null) void load(false)
|
||||
}}
|
||||
onEndReachedThreshold={0.25}
|
||||
testID="dictionary-list"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={editor !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeEditor}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.modalBackdrop}
|
||||
>
|
||||
{editor !== null && (
|
||||
<View
|
||||
style={styles.modalCard}
|
||||
accessibilityViewIsModal
|
||||
testID="dictionary-editor"
|
||||
>
|
||||
<ThemeText variant="title" accessibilityRole="header">
|
||||
{editor.entry === null
|
||||
? t('mobile.dictionary.addTitle')
|
||||
: t('mobile.dictionary.editTitle')}
|
||||
</ThemeText>
|
||||
<View style={styles.field}>
|
||||
<ThemeText variant="label" color="muted">{t('mobile.dictionary.word')}</ThemeText>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.dictionary.word')}
|
||||
value={editor.word}
|
||||
onChangeText={(word) => setEditor((current) => current === null
|
||||
? null
|
||||
: { ...current, word })}
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.editorInput}
|
||||
testID="dictionary-editor-word"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<ThemeText variant="label" color="muted">
|
||||
{t('mobile.dictionary.pronunciation')}
|
||||
</ThemeText>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.dictionary.pronunciation')}
|
||||
value={editor.pronunciation}
|
||||
onChangeText={(pronunciation) => setEditor((current) => current === null
|
||||
? null
|
||||
: { ...current, pronunciation })}
|
||||
maxLength={200}
|
||||
autoCapitalize="none"
|
||||
style={styles.editorInput}
|
||||
testID="dictionary-editor-pronunciation"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<ThemeText variant="label" color="muted">{t('mobile.dictionary.category')}</ThemeText>
|
||||
<View style={styles.editorCategories}>
|
||||
{CATEGORY_FILTERS.filter((value): value is DictionaryCategory => value !== 'all')
|
||||
.map((value) => {
|
||||
const active = editor.category === value
|
||||
return (
|
||||
<Pressable
|
||||
key={value}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: active }}
|
||||
onPress={() => setEditor((current) => current === null
|
||||
? null
|
||||
: { ...current, category: value })}
|
||||
style={[styles.editorCategory, active && styles.filterActive]}
|
||||
testID={`dictionary-editor-category-${value}`}
|
||||
>
|
||||
<ThemeText variant="label" color={active ? 'onAccent' : 'muted'}>
|
||||
{t(categoryKey(value))}
|
||||
</ThemeText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
{editorError !== null && (
|
||||
<ThemeText
|
||||
color="danger"
|
||||
accessibilityLiveRegion="assertive"
|
||||
testID="dictionary-editor-error"
|
||||
>
|
||||
{t(errorKey(editorError))}
|
||||
</ThemeText>
|
||||
)}
|
||||
<View style={styles.modalActions}>
|
||||
<ThemeButton
|
||||
label={t('mobile.dictionary.cancel')}
|
||||
variant="secondary"
|
||||
disabled={saving}
|
||||
onPress={closeEditor}
|
||||
style={styles.modalButton}
|
||||
testID="dictionary-editor-cancel"
|
||||
/>
|
||||
<ThemeButton
|
||||
label={saving
|
||||
? t('mobile.dictionary.saving')
|
||||
: t('mobile.dictionary.save')}
|
||||
disabled={saving || editor.word.trim().length === 0}
|
||||
onPress={() => void saveEditor()}
|
||||
style={styles.modalButton}
|
||||
testID="dictionary-editor-save"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type Palette = ReturnType<typeof useMobilePreferences>['palette']
|
||||
|
||||
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: palette.bg.app },
|
||||
header: {
|
||||
minHeight: 86,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: palette.border.default,
|
||||
backgroundColor: palette.bg.sidebar,
|
||||
},
|
||||
addButton: { minWidth: 86 },
|
||||
realtimeBanner: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: palette.accent.dim,
|
||||
},
|
||||
searchArea: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: palette.border.subtle,
|
||||
gap: 10,
|
||||
},
|
||||
inlineError: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: palette.tag.red,
|
||||
backgroundColor: palette.bg.card,
|
||||
},
|
||||
inlineRetry: { minWidth: 82, minHeight: 42 },
|
||||
searchInput: {
|
||||
minHeight: 48,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.default,
|
||||
backgroundColor: palette.bg.inset,
|
||||
color: palette.text.primary,
|
||||
paddingHorizontal: 14,
|
||||
fontSize: 14,
|
||||
},
|
||||
filters: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
filter: {
|
||||
minHeight: 40,
|
||||
minWidth: 62,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 9,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.default,
|
||||
backgroundColor: palette.bg.card,
|
||||
},
|
||||
filterActive: {
|
||||
backgroundColor: palette.accent.main,
|
||||
borderColor: palette.accent.main,
|
||||
},
|
||||
listContent: { padding: 20 },
|
||||
emptyList: { flexGrow: 1 },
|
||||
centerState: {
|
||||
flex: 1,
|
||||
minHeight: 280,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 14,
|
||||
padding: 28,
|
||||
},
|
||||
centerText: { textAlign: 'center', lineHeight: 21 },
|
||||
entryCard: { marginBottom: 12, gap: 10 },
|
||||
entryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
entryText: { flex: 1, gap: 4 },
|
||||
categoryBadge: {
|
||||
minHeight: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 9,
|
||||
borderRadius: 8,
|
||||
backgroundColor: palette.bg.inset,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.subtle,
|
||||
},
|
||||
entryActions: { flexDirection: 'row', gap: 10, marginTop: 4 },
|
||||
entryButton: { flex: 1, minHeight: 44 },
|
||||
loadMore: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: 16,
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.72)',
|
||||
},
|
||||
modalCard: {
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.strong,
|
||||
backgroundColor: palette.bg.elevated,
|
||||
},
|
||||
field: { gap: 7 },
|
||||
editorInput: {
|
||||
minHeight: 48,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 9,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.default,
|
||||
backgroundColor: palette.bg.inset,
|
||||
color: palette.text.primary,
|
||||
fontSize: 15,
|
||||
},
|
||||
editorCategories: { flexDirection: 'row', gap: 8 },
|
||||
editorCategory: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 9,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.default,
|
||||
backgroundColor: palette.bg.card,
|
||||
},
|
||||
modalActions: { flexDirection: 'row', gap: 10, marginTop: 2 },
|
||||
modalButton: { flex: 1 },
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue