diff --git a/app/_layout.tsx b/app/_layout.tsx
index d717c04f7..0f8c4ab12 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -78,6 +78,11 @@ export default function RootLayout() {
});
useEffect(() => {
+ // async function init() {
+ // await tagService.preloadTagsIntoCache();
+ // }
+ // void init();
+
if (Platform.OS === 'web') return;
console.log('[_layout] Setting up deep link handler');
diff --git a/components/AddVerseLabelButton.tsx b/components/AddVerseLabelButton.tsx
new file mode 100644
index 000000000..d9c5e466a
--- /dev/null
+++ b/components/AddVerseLabelButton.tsx
@@ -0,0 +1,37 @@
+import { PlusCircleIcon } from 'lucide-react-native';
+import React from 'react';
+import { Pressable, View } from 'react-native';
+import { Icon } from './ui/icon';
+import { Text } from './ui/text';
+
+interface AddVerseLabelButtonProps {
+ onPress: () => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function AddVerseLabelButton({
+ onPress,
+ disabled = false,
+ className = ''
+}: AddVerseLabelButtonProps) {
+ return (
+
+ {/* */}
+
+
+
+
+ Add verse
+
+
+ {/* */}
+
+ );
+}
diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx
index 47c4e7a8e..01d867636 100644
--- a/components/ArrayInsertionWheel.tsx
+++ b/components/ArrayInsertionWheel.tsx
@@ -12,47 +12,66 @@ export interface ArrayInsertionWheelHandle {
scrollItemToTop: (index: number, animated?: boolean) => void;
}
-interface ArrayInsertionWheelProps {
- children: React.ReactNode[];
+interface ArrayInsertionWheelPropsBase {
value: number; // 0..N insertion boundary
onChange?: (index: number) => void;
rowHeight: number;
className?: string;
topInset?: number; // unused in native wheel, kept for API parity
bottomInset?: number; // unused in native wheel, kept for API parity
+ boundaryComponent?: React.ReactNode;
+}
+
+// API 1: Eager rendering with children (backward compatible)
+interface ArrayInsertionWheelPropsEager extends ArrayInsertionWheelPropsBase {
+ children: React.ReactNode[];
+ data?: never;
+ renderItem?: never;
+}
+
+// API 2: Lazy rendering with data + renderItem (optimized)
+interface ArrayInsertionWheelPropsLazy extends ArrayInsertionWheelPropsBase {
+ children?: never;
+ data: T[];
+ renderItem: (item: T, index: number) => React.ReactElement;
}
-function ArrayInsertionWheelInternal(
- {
- children,
+type ArrayInsertionWheelProps =
+ | ArrayInsertionWheelPropsEager
+ | ArrayInsertionWheelPropsLazy;
+
+function ArrayInsertionWheelInternal(
+ props: ArrayInsertionWheelProps,
+ ref: React.Ref
+) {
+ const {
value,
onChange,
rowHeight,
className,
topInset = 0,
- bottomInset = 0
- }: ArrayInsertionWheelProps,
- ref: React.Ref
-) {
- const itemCount = children.length + 1; // extra end boundary
+ bottomInset = 0,
+ boundaryComponent
+ } = props;
+
+ // Determine which API is being used
+ const isLazyMode = 'data' in props && props.data !== undefined;
+
+ // Calculate item count based on mode
+ let itemCount: number;
+ if (isLazyMode) {
+ itemCount = props.data.length + 1;
+ } else {
+ // Eager mode - children is guaranteed by type
+ itemCount = props.children.length + 1;
+ }
+
const clampedValue = Math.max(0, Math.min(itemCount - 1, value));
// Stabilize clampedValue to prevent unnecessary WheelPicker updates
- const prevClampedRef = React.useRef(clampedValue);
const stableClampedValue = React.useMemo(() => {
- if (prevClampedRef.current !== clampedValue) {
- console.log(
- 'π Wheel value changed:',
- prevClampedRef.current,
- 'β',
- clampedValue,
- '| itemCount:',
- itemCount
- );
- prevClampedRef.current = clampedValue;
- }
return clampedValue;
- }, [clampedValue, itemCount]);
+ }, [clampedValue]);
// Debug logging to trace clamping
React.useEffect(() => {
@@ -67,7 +86,7 @@ function ArrayInsertionWheelInternal(
}
}, [value, clampedValue, itemCount]);
- const data = React.useMemo[]>(
+ const pickerData = React.useMemo[]>(
() => Array.from({ length: itemCount }, (_, i) => ({ value: i })),
[itemCount]
);
@@ -116,22 +135,50 @@ function ArrayInsertionWheelInternal(
[stableClampedValue, itemCount, onChange]
);
- const renderItem = React.useCallback(
- ({ item }: { item: PickerItem }) => {
+ const renderItemInternal = React.useCallback(
+ ({ item }: { item: PickerItem }): React.ReactElement => {
const i = item.value;
+
+ // Calculate data length based on mode
+ let dataLength: number;
+ if (isLazyMode) {
+ dataLength = props.data.length;
+ } else {
+ dataLength = props.children.length;
+ }
// Render actual items (not the final boundary)
- if (i < children.length) {
- return (
-
- {children[i]}
-
- );
+ if (i < dataLength) {
+ if (isLazyMode) {
+ // Lazy mode: call renderItem with data item
+ const dataItem = props.data[i];
+ if (!dataItem) {
+ // Defensive: should never happen, but TypeScript needs this
+ return ;
+ }
+ return (
+
+ {props.renderItem(dataItem, i)}
+
+ );
+ } else {
+ // Eager mode: use pre-created children
+ const child = props.children[i];
+ return (
+
+ {child}
+
+ );
+ }
}
- // Final boundary (i === children.length)
+ // Final boundary (i === dataLength)
// When empty (0 items), this is position 0 - the only insertion point
// When non-empty, this is position N - insert after all items
+ if (boundaryComponent) {
+ return <>{boundaryComponent}>;
+ }
+
return (
);
},
- [children, rowHeight]
+ [isLazyMode, props, rowHeight, boundaryComponent]
);
return (
@@ -174,7 +221,7 @@ function ArrayInsertionWheelInternal(
onLayout={onContainerLayout}
>
onChange?.(item.value)}
// Constrain height to an exact multiple of rowHeight so overlay aligns
style={{ height: wheelHeight }}
- renderItem={renderItem}
+ renderItem={renderItemInternal}
renderItemContainer={({ key, ...props }) => (
)}
@@ -209,13 +256,10 @@ function ArrayInsertionWheelInternal(
);
}
-export default React.forwardRef<
- ArrayInsertionWheelHandle,
- ArrayInsertionWheelProps
->(
- ArrayInsertionWheelInternal as unknown as (
- props: ArrayInsertionWheelProps & {
- ref?: React.Ref;
- }
- ) => React.ReactElement
-);
+const ArrayInsertionWheel = React.forwardRef(ArrayInsertionWheelInternal) as (
+ props: ArrayInsertionWheelProps & {
+ ref?: React.Ref;
+ }
+) => React.ReactElement;
+
+export default ArrayInsertionWheel;
diff --git a/components/AssetsDeletionDrawer.tsx b/components/AssetsDeletionDrawer.tsx
new file mode 100644
index 000000000..7e1b29a08
--- /dev/null
+++ b/components/AssetsDeletionDrawer.tsx
@@ -0,0 +1,125 @@
+import { Button } from '@/components/ui/button';
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle
+} from '@/components/ui/drawer';
+import { Input } from '@/components/ui/input';
+import { Text } from '@/components/ui/text';
+import { useLocalization } from '@/hooks/useLocalization';
+import { AlertTriangleIcon } from 'lucide-react-native';
+import React from 'react';
+import { View } from 'react-native';
+import { Icon } from './ui/icon';
+
+interface AssetsDeletionDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void | Promise;
+ title: string;
+ description: string;
+ confirmationString: string; // String that user must type to confirm deletion
+}
+
+export const AssetsDeletionDrawer: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ description,
+ confirmationString
+}) => {
+ const { t } = useLocalization();
+ const [inputValue, setInputValue] = React.useState('');
+ const [isExecuting, setIsExecuting] = React.useState(false);
+
+ // Reset input when drawer opens
+ React.useEffect(() => {
+ if (isOpen) {
+ setInputValue('');
+ setIsExecuting(false);
+ }
+ }, [isOpen]);
+
+ const handleConfirm = async () => {
+ if (inputValue !== confirmationString || isExecuting) return;
+
+ setIsExecuting(true);
+ try {
+ await onConfirm();
+ onClose();
+ } catch (error) {
+ console.error('Error executing deletion:', error);
+ } finally {
+ setIsExecuting(false);
+ }
+ };
+
+ const isButtonDisabled = inputValue !== confirmationString || isExecuting;
+
+ return (
+ !open && onClose()}>
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+ {t('typeToConfirm').replace(
+ '{text}',
+ `"${confirmationString}"`
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/QuestionModal.tsx b/components/QuestionModal.tsx
new file mode 100644
index 000000000..1ce0e1f60
--- /dev/null
+++ b/components/QuestionModal.tsx
@@ -0,0 +1,78 @@
+import { Button } from '@/components/ui/button';
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle
+} from '@/components/ui/drawer';
+import { Text } from '@/components/ui/text';
+import React from 'react';
+import { View } from 'react-native';
+
+interface QuestionModalProps {
+ visible: boolean;
+ title: string;
+ description: string;
+ onYes: () => void;
+ onNo: () => void;
+ onClose?: () => void;
+}
+
+export function QuestionModal({
+ visible,
+ title,
+ description,
+ onYes,
+ onNo,
+ onClose
+}: QuestionModalProps) {
+ const [isOpen, setIsOpen] = React.useState(visible);
+
+ // Sync internal state with prop
+ React.useEffect(() => {
+ setIsOpen(visible);
+ }, [visible]);
+
+ const handleYes = () => {
+ onYes();
+ setIsOpen(false);
+ onClose?.();
+ };
+
+ const handleNo = () => {
+ onNo();
+ setIsOpen(false);
+ onClose?.();
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+ if (!open) {
+ onClose?.();
+ }
+ };
+
+ return (
+
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/SectionSeparator.tsx b/components/SectionSeparator.tsx
new file mode 100644
index 000000000..6599302c1
--- /dev/null
+++ b/components/SectionSeparator.tsx
@@ -0,0 +1,42 @@
+import { Text, View } from 'react-native';
+
+type SectionSeparatorVariant = 'default' | 'small' | 'xs';
+
+interface SectionSeparatorProps {
+ text: string;
+ variant?: SectionSeparatorVariant;
+ className?: string;
+ textClassName?: string;
+}
+
+const variantStyles = {
+ default: {
+ line: 'bg-primary',
+ text: 'text-base text-primary'
+ },
+ small: {
+ line: 'bg-primary/60',
+ text: 'text-sm text-primary/60'
+ },
+ xs: {
+ line: 'bg-primary/40',
+ text: 'text-xs text-primary/40'
+ }
+};
+
+export function SectionSeparator({
+ text,
+ variant = 'default',
+ className = '',
+ textClassName = ''
+}: SectionSeparatorProps) {
+ const styles = variantStyles[variant];
+
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/components/SortListCombo.tsx b/components/SortListCombo.tsx
new file mode 100644
index 000000000..bb85dbd68
--- /dev/null
+++ b/components/SortListCombo.tsx
@@ -0,0 +1,158 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ getOptionFromValue
+} from '@/components/ui/select';
+import { Text } from '@/components/ui/text';
+import React from 'react';
+import { View } from 'react-native';
+
+export interface SortListComboProps {
+ options: string[];
+ onSelectionChange?: (selection: {
+ first: string | undefined;
+ second: string | undefined;
+ third: string | undefined;
+ }) => void;
+ className?: string;
+ placeholder1?: string;
+ placeholder2?: string;
+ placeholder3?: string;
+}
+
+export function SortListCombo({
+ options,
+ onSelectionChange,
+ className = '',
+ placeholder1 = 'Select first option',
+ placeholder2 = 'Select second option',
+ placeholder3 = 'Select third option'
+}: SortListComboProps) {
+ const [selected1, setSelected1] = React.useState();
+ const [selected2, setSelected2] = React.useState();
+ const [selected3, setSelected3] = React.useState();
+
+ // Notify parent when selection changes
+ React.useEffect(() => {
+ onSelectionChange?.({
+ first: selected1,
+ second: selected2,
+ third: selected3
+ });
+ }, [selected1, selected2, selected3, onSelectionChange]);
+
+ // Filter options for second combobox (exclude selected1)
+ const options2 = React.useMemo(() => {
+ return options.filter((option) => option !== selected1);
+ }, [options, selected1]);
+
+ // Filter options for third combobox (exclude selected1 and selected2)
+ const options3 = React.useMemo(() => {
+ return options.filter(
+ (option) => option !== selected1 && option !== selected2
+ );
+ }, [options, selected1, selected2]);
+
+ const handleFirstChange = (value: string) => {
+ setSelected1(value);
+ setSelected2(undefined);
+ setSelected3(undefined);
+ };
+
+ const handleSecondChange = (value: string) => {
+ setSelected2(value);
+ setSelected3(undefined);
+ };
+
+ return (
+
+ Group by
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/TagModal.tsx b/components/TagModal.tsx
new file mode 100644
index 000000000..8edd0646b
--- /dev/null
+++ b/components/TagModal.tsx
@@ -0,0 +1,296 @@
+/**
+ * TagModal - Modal for assigning tags
+ */
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Text } from '@/components/ui/text';
+import type { Tag } from '@/database_services/tagCache';
+import { useSearchTags } from '@/hooks/db/useSearchTags';
+import React from 'react';
+import type { TextInput } from 'react-native';
+import { Modal, Pressable, ScrollView, View } from 'react-native';
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming
+} from 'react-native-reanimated';
+
+interface TagModalProps {
+ isVisible: boolean;
+ selectedTag?: Tag;
+ initialSelectedTags?: Tag[];
+ searchTerm?: string;
+ limit?: number;
+ onClose: () => void;
+ onAssignTags: (tags: Tag[]) => void;
+}
+
+export function TagModal({
+ isVisible,
+ selectedTag,
+ initialSelectedTags = [],
+ searchTerm = '',
+ limit = 20,
+ onClose,
+ onAssignTags
+}: TagModalProps) {
+ const [localSearchTerm, setLocalSearchTerm] = React.useState(searchTerm);
+ const [selectedTags, setSelectedTags] = React.useState(() => {
+ if (selectedTag) return [selectedTag];
+ return initialSelectedTags;
+ });
+ const [modalVisible, setModalVisible] = React.useState(false);
+ const inputRef = React.useRef(null);
+
+ const { tags = [], isTagsLoading } = useSearchTags({
+ searchTerm: searchTerm || localSearchTerm,
+ maxResults: limit,
+ enabled: isVisible
+ });
+ const opacity = useSharedValue(0);
+ const scale = useSharedValue(0.9);
+
+ // Reset state when modal opens or searchTerm changes
+ React.useEffect(() => {
+ if (isVisible) {
+ setLocalSearchTerm(searchTerm);
+ if (selectedTag) {
+ setSelectedTags([selectedTag]);
+ } else {
+ setSelectedTags(initialSelectedTags);
+ }
+ }
+ }, [isVisible, searchTerm, selectedTag, initialSelectedTags]);
+
+ // Handle modal visibility with exit animation
+ React.useEffect(() => {
+ if (isVisible) {
+ // Show modal immediately
+ setModalVisible(true);
+ // Quick, snappy animation (Emil Kowalski style)
+ opacity.value = withTiming(1, {
+ duration: 150,
+ easing: Easing.out(Easing.ease)
+ });
+ scale.value = withTiming(1, {
+ duration: 150,
+ easing: Easing.out(Easing.ease)
+ });
+
+ // Focus after animation completes
+ const focusTimer = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 150);
+
+ // Backup focus attempt
+ const backupTimer = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 200);
+
+ return () => {
+ clearTimeout(focusTimer);
+ clearTimeout(backupTimer);
+ };
+ } else {
+ // Exit animation - quick fade out (ease-out for responsiveness)
+ opacity.value = withTiming(0, {
+ duration: 100,
+ easing: Easing.out(Easing.ease)
+ });
+ scale.value = withTiming(0.9, {
+ duration: 100,
+ easing: Easing.out(Easing.ease)
+ });
+
+ // Hide modal after exit animation completes
+ const hideTimer = setTimeout(() => {
+ setModalVisible(false);
+ }, 100);
+
+ return () => {
+ clearTimeout(hideTimer);
+ };
+ }
+ }, [isVisible, opacity, scale]);
+
+ const handleAssign = () => {
+ onAssignTags(selectedTags);
+ onClose();
+ };
+
+ const handleTagToggle = (tag: Tag) => {
+ setSelectedTags((prev) => {
+ const isSelected = prev.some((t) => t.id === tag.id);
+ if (isSelected) {
+ return prev.filter((t) => t.id !== tag.id);
+ } else {
+ return [...prev, tag];
+ }
+ });
+ };
+
+ const handleCancel = () => {
+ setLocalSearchTerm(searchTerm);
+ if (selectedTag) {
+ setSelectedTags([selectedTag]);
+ } else {
+ setSelectedTags(initialSelectedTags);
+ }
+ onClose();
+ };
+
+ const formatTagText = (tag: Tag) => {
+ return tag.value ? `${tag.key}:${tag.value}` : tag.key;
+ };
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ scale: scale.value }]
+ }));
+
+ if (!modalVisible) return null;
+
+ return (
+
+
+
+ e.stopPropagation()}>
+
+
+ Assign Tags
+
+
+ {/* Show search input only if no initial search term */}
+ {!searchTerm && (
+
+ )}
+
+ {/* Selected tags display */}
+ {selectedTags.length > 0 && (
+
+
+ Selected Tags ({selectedTags.length}):
+
+
+ {selectedTags.map((tag, index) => {
+ const isFirstTag = index === 0;
+ return (
+ handleTagToggle(tag)}
+ className={`mr-2 rounded-full px-3 py-1 ${
+ isFirstTag ? 'bg-primary' : 'bg-primary/90'
+ }`}
+ >
+
+ {formatTagText(tag)} β
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Tags list */}
+
+
+ Available Tags:
+
+ {isTagsLoading ? (
+ Loading...
+ ) : tags.length === 0 ? (
+
+ {localSearchTerm ? 'No tags found' : 'No tags available'}
+
+ ) : (
+
+
+ {tags.map((tag) => {
+ const isSelected = selectedTags.some(
+ (t) => t.id === tag.id
+ );
+ const isFirstSelected =
+ selectedTags.length > 0 &&
+ selectedTags[0]?.id === tag.id;
+ return (
+ handleTagToggle(tag)}
+ className={`mb-2 mr-2 rounded-full px-3 py-1 ${
+ isFirstSelected
+ ? 'bg-primary'
+ : isSelected
+ ? 'bg-primary/90'
+ : 'border border-border bg-background'
+ }`}
+ >
+
+ {formatTagText(tag)}
+ {isSelected && ' β'}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/VerseAssigner.tsx b/components/VerseAssigner.tsx
new file mode 100644
index 000000000..0418d47fa
--- /dev/null
+++ b/components/VerseAssigner.tsx
@@ -0,0 +1,378 @@
+import { XIcon } from 'lucide-react-native';
+import React from 'react';
+import { Pressable, ScrollView, View } from 'react-native';
+import { Button } from './ui/button';
+import { Icon } from './ui/icon';
+import { Text } from './ui/text';
+
+export interface ExistingLabel {
+ from: number;
+ to: number;
+}
+
+// Unified list item type
+type ListItem =
+ | { type: 'available'; verse: number }
+ | { type: 'existing'; from: number; to: number };
+
+interface VerseAssignerProps {
+ // Either provide availableVerses array OR from/to range (for backward compatibility)
+ availableVerses?: number[];
+ from?: number;
+ to?: number;
+ selectedFrom?: number;
+ selectedTo?: number;
+ onApply: (from: number, to: number) => void;
+ onCancel: () => void;
+ onRemove?: () => void; // Optional callback to remove labels from selected assets
+ hasSelectedAssetsWithLabels?: boolean; // Whether selected assets have labels to remove
+ className?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ScrollViewComponent?: React.ComponentType;
+ // Optional function to limit the maximum "to" value based on selected "from"
+ getMaxToForFrom?: (selectedFrom: number) => number;
+ // Existing verse labels to display for quick selection
+ existingLabels?: ExistingLabel[];
+ // Total verse count for the chapter (to show all verses in unified list)
+ verseCount?: number;
+}
+
+export function VerseAssigner({
+ availableVerses,
+ from,
+ to,
+ selectedFrom: initialFrom,
+ selectedTo: initialTo,
+ onApply,
+ onCancel,
+ onRemove,
+ hasSelectedAssetsWithLabels = false,
+ className = '',
+ ScrollViewComponent = ScrollView,
+ getMaxToForFrom,
+ existingLabels = [],
+ verseCount
+}: VerseAssignerProps) {
+ const [selectedFrom, setSelectedFrom] = React.useState(
+ initialFrom
+ );
+ const [selectedTo, setSelectedTo] = React.useState(
+ initialTo
+ );
+
+ // Track if selection came from an existing label (to prevent range editing)
+ const [isExistingSelected, setIsExistingSelected] = React.useState(false);
+
+ // Generate array of available numbers
+ const availableSet = React.useMemo(() => {
+ const set = new Set();
+ if (availableVerses && availableVerses.length > 0) {
+ availableVerses.forEach((v) => set.add(v));
+ } else if (from !== undefined && to !== undefined) {
+ for (let i = from; i <= to; i++) {
+ set.add(i);
+ }
+ }
+ return set;
+ }, [availableVerses, from, to]);
+
+ // Determine total verse range
+ const totalVerseCount = React.useMemo(() => {
+ if (verseCount) return verseCount;
+ // Calculate from available verses and existing labels
+ let max = 0;
+ availableSet.forEach((v) => {
+ if (v > max) max = v;
+ });
+ existingLabels.forEach((label) => {
+ if (label.to > max) max = label.to;
+ });
+ return max || to || 1;
+ }, [verseCount, availableSet, existingLabels, to]);
+
+ // Build unified list: interleave available verses with existing labels
+ const unifiedList = React.useMemo(() => {
+ const items: ListItem[] = [];
+ const existingMap = new Map(); // Maps 'from' to label
+
+ // Index existing labels by their starting verse
+ for (const label of existingLabels) {
+ existingMap.set(label.from, label);
+ }
+
+ // Build a set of verses covered by existing labels
+ const coveredByExisting = new Set();
+ for (const label of existingLabels) {
+ for (let v = label.from; v <= label.to; v++) {
+ coveredByExisting.add(v);
+ }
+ }
+
+ let verse = 1;
+ while (verse <= totalVerseCount) {
+ // Check if this verse starts an existing label
+ const existingLabel = existingMap.get(verse);
+ if (existingLabel) {
+ items.push({
+ type: 'existing',
+ from: existingLabel.from,
+ to: existingLabel.to
+ });
+ // Skip to after the label's range
+ verse = existingLabel.to + 1;
+ } else if (availableSet.has(verse)) {
+ // Available verse (not part of any existing label)
+ items.push({ type: 'available', verse });
+ verse++;
+ } else {
+ // Verse is not available and not in existing label - skip it
+ verse++;
+ }
+ }
+
+ return items;
+ }, [totalVerseCount, existingLabels, availableSet]);
+
+ // Calculate max "to" value when "from" is selected
+ const maxTo: number = React.useMemo(() => {
+ if (selectedFrom === undefined) {
+ return totalVerseCount;
+ }
+ if (getMaxToForFrom) {
+ return getMaxToForFrom(selectedFrom);
+ }
+ return totalVerseCount;
+ }, [selectedFrom, getMaxToForFrom, totalVerseCount]);
+
+ // Check if a verse is selectable for range selection
+ const isVerseSelectable = React.useCallback(
+ (verse: number) => {
+ if (!availableSet.has(verse)) return false;
+
+ // If nothing selected, all available are selectable
+ if (selectedFrom === undefined) return true;
+
+ // If existing label is selected, nothing else is selectable
+ if (isExistingSelected) return false;
+
+ // If only "from" selected, only verses >= selectedFrom and <= maxTo are selectable
+ if (selectedTo === undefined) {
+ return verse >= selectedFrom && verse <= maxTo;
+ }
+
+ // If both selected, nothing is selectable
+ return false;
+ },
+ [selectedFrom, selectedTo, maxTo, availableSet, isExistingSelected]
+ );
+
+ const handleAvailablePress = (verse: number) => {
+ if (!isVerseSelectable(verse)) return;
+
+ if (selectedFrom === undefined) {
+ // First selection
+ setSelectedFrom(verse);
+ setSelectedTo(undefined);
+ setIsExistingSelected(false);
+ } else if (selectedTo === undefined && !isExistingSelected) {
+ // Second selection for range
+ setSelectedTo(Math.min(verse, maxTo));
+ }
+ };
+
+ const handleExistingPress = (label: ExistingLabel) => {
+ // If already selected, deselect
+ if (selectedFrom === label.from && selectedTo === label.to) {
+ setSelectedFrom(undefined);
+ setSelectedTo(undefined);
+ setIsExistingSelected(false);
+ } else {
+ // Select this existing label
+ setSelectedFrom(label.from);
+ setSelectedTo(label.to);
+ setIsExistingSelected(true);
+ }
+ };
+
+ const handleClear = () => {
+ setSelectedFrom(undefined);
+ setSelectedTo(undefined);
+ setIsExistingSelected(false);
+ };
+
+ const handleApply = () => {
+ if (selectedFrom !== undefined && selectedTo !== undefined) {
+ onApply(selectedFrom, selectedTo);
+ } else if (selectedFrom !== undefined) {
+ onApply(selectedFrom, selectedFrom);
+ }
+ };
+
+ const canApply = selectedFrom !== undefined;
+
+ return (
+
+ {/* Unified verse list */}
+
+ {unifiedList.map((item) => {
+ if (item.type === 'existing') {
+ // Existing label - render as a pill
+ const isSelected =
+ selectedFrom === item.from && selectedTo === item.to;
+ const labelText =
+ item.from === item.to
+ ? `${item.from}`
+ : `${item.from} - ${item.to}`;
+ // Disable existing labels when user is selecting a new range
+ const isDisabled =
+ selectedFrom !== undefined && !isExistingSelected;
+
+ return (
+ handleExistingPress(item)}
+ disabled={isDisabled}
+ className={`h-10 items-center justify-center rounded-full px-3 ${
+ isSelected
+ ? 'bg-primary'
+ : isDisabled
+ ? 'bg-muted/30'
+ : 'border border-primary/30 bg-primary/5'
+ } ${!isDisabled ? 'active:scale-95' : ''}`}
+ >
+
+ {labelText}
+
+
+ );
+ } else {
+ // Available verse - render as circle
+ const verse = item.verse;
+ const isSelectedFrom = verse === selectedFrom;
+ const isSelectedTo = verse === selectedTo;
+ const isSelected = isSelectedFrom || isSelectedTo;
+ const selectable = isVerseSelectable(verse);
+
+ return (
+ handleAvailablePress(verse)}
+ disabled={!selectable}
+ className={`h-10 w-10 items-center justify-center rounded-full ${
+ isSelected
+ ? 'bg-primary'
+ : selectable
+ ? 'border border-primary/30 bg-primary/5'
+ : 'bg-muted/30'
+ } ${selectable ? 'active:scale-95' : ''}`}
+ >
+
+ {verse}
+
+
+ );
+ }
+ })}
+
+
+ {/* Selected inputs */}
+
+ {/* From input */}
+
+ {selectedFrom !== undefined ? (
+ <>
+
+ {selectedFrom}
+
+
+ >
+ ) : (
+ From
+ )}
+
+
+ β
+
+ {/* To input */}
+
+ {selectedTo !== undefined ? (
+ <>
+
+ {selectedTo}
+
+
+ >
+ ) : (
+ To
+ )}
+
+
+
+ {/* Action buttons */}
+
+
+ {selectedFrom !== undefined ? (
+ // Show Apply when verse is selected
+
+ ) : (
+ // Show Remove when no selection but assets have labels
+ onRemove &&
+ hasSelectedAssetsWithLabels && (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/components/VersePill.tsx b/components/VersePill.tsx
new file mode 100644
index 000000000..def7081ee
--- /dev/null
+++ b/components/VersePill.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { View } from 'react-native';
+import { Text } from './ui/text';
+
+interface VersePillProps {
+ text: string;
+ className?: string;
+ largeText?: boolean;
+}
+
+const VersePillComponent = ({
+ text,
+ className = '',
+ largeText = false
+}: VersePillProps) => {
+ return (
+
+
+
+ {text}
+
+
+
+ );
+};
+
+/**
+ * Memoized VersePill component
+ * Only re-renders when text, className, or largeText changes
+ *
+ * Performance: Prevents unnecessary re-renders when assets list changes
+ * but verse pills remain the same (common scenario in BibleRecordingView)
+ */
+export const VersePill = React.memo(
+ VersePillComponent,
+ (prevProps, nextProps) => {
+ // Return TRUE if props are EQUAL (skip re-render)
+ // Return FALSE if props are DIFFERENT (re-render needed)
+ return (
+ prevProps.text === nextProps.text &&
+ prevProps.className === nextProps.className &&
+ prevProps.largeText === nextProps.largeText
+ );
+ }
+);
+
+VersePill.displayName = 'VersePill';
diff --git a/components/VerseRangeSelector.tsx b/components/VerseRangeSelector.tsx
new file mode 100644
index 000000000..cbb6a6922
--- /dev/null
+++ b/components/VerseRangeSelector.tsx
@@ -0,0 +1,241 @@
+import { XIcon } from 'lucide-react-native';
+import React from 'react';
+import { Pressable, ScrollView, View } from 'react-native';
+import { Button } from './ui/button';
+import { Icon } from './ui/icon';
+import { Text } from './ui/text';
+
+interface VerseRangeSelectorProps {
+ // Either provide availableVerses array OR from/to range (for backward compatibility)
+ availableVerses?: number[];
+ from?: number;
+ to?: number;
+ selectedFrom?: number;
+ selectedTo?: number;
+ onApply: (from: number, to: number) => void;
+ onCancel: () => void;
+ className?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ScrollViewComponent?: React.ComponentType;
+ // Optional function to limit the maximum "to" value based on selected "from"
+ getMaxToForFrom?: (selectedFrom: number) => number;
+}
+
+export function VerseRangeSelector({
+ availableVerses,
+ from,
+ to,
+ selectedFrom: initialFrom,
+ selectedTo: initialTo,
+ onApply,
+ onCancel,
+ className = '',
+ ScrollViewComponent = ScrollView,
+ getMaxToForFrom
+}: VerseRangeSelectorProps) {
+ const [selectedFrom, setSelectedFrom] = React.useState(
+ initialFrom
+ );
+ const [selectedTo, setSelectedTo] = React.useState(
+ initialTo
+ );
+
+ // Generate array of numbers - use availableVerses if provided, otherwise generate from/to range
+ const allNumbers = React.useMemo(() => {
+ if (availableVerses && availableVerses.length > 0) {
+ return availableVerses;
+ }
+ // Fallback to from/to range for backward compatibility
+ if (from !== undefined && to !== undefined) {
+ const numbers: number[] = [];
+ for (let i = from; i <= to; i++) {
+ numbers.push(i);
+ }
+ return numbers;
+ }
+ return [];
+ }, [availableVerses, from, to]);
+
+ // Calculate max "to" value when "from" is selected
+ const maxTo: number = React.useMemo(() => {
+ if (selectedFrom === undefined) {
+ return allNumbers.length > 0
+ ? allNumbers[allNumbers.length - 1]!
+ : to || 1;
+ }
+ if (getMaxToForFrom) {
+ return getMaxToForFrom(selectedFrom);
+ }
+ // If no getMaxToForFrom function, allow up to the last available verse
+ return allNumbers.length > 0 ? allNumbers[allNumbers.length - 1]! : to || 1;
+ }, [selectedFrom, getMaxToForFrom, allNumbers, to]);
+
+ // Check if a number is selectable based on current selection state
+ const isNumberSelectable = React.useCallback(
+ (num: number) => {
+ // Number must be in the available numbers list
+ if (!allNumbers.includes(num)) {
+ return false;
+ }
+
+ // If nothing selected, all available numbers are selectable
+ if (selectedFrom === undefined) {
+ return true;
+ }
+ // If only "from" selected, only numbers >= selectedFrom and <= maxTo are selectable
+ if (selectedTo === undefined) {
+ return num >= selectedFrom && num <= maxTo && allNumbers.includes(num);
+ }
+ // If both selected, nothing is selectable (user must clear first)
+ return false;
+ },
+ [selectedFrom, selectedTo, maxTo, allNumbers]
+ );
+
+ const handleNumberPress = (num: number) => {
+ if (!isNumberSelectable(num)) return;
+
+ if (selectedFrom === undefined) {
+ // First selection - set "from"
+ setSelectedFrom(num);
+ // Clear "to" if it was set, since maxTo might have changed
+ setSelectedTo(undefined);
+ } else if (selectedTo === undefined) {
+ // Second selection - set "to" (but limit to maxTo)
+ setSelectedTo(Math.min(num, maxTo));
+ }
+ };
+
+ const handleClearFrom = () => {
+ setSelectedFrom(undefined);
+ setSelectedTo(undefined); // Clear both since "to" depends on "from"
+ };
+
+ const handleClearTo = () => {
+ setSelectedTo(undefined);
+ };
+
+ const handleApply = () => {
+ if (selectedFrom !== undefined && selectedTo !== undefined) {
+ onApply(selectedFrom, selectedTo);
+ } else if (selectedFrom !== undefined) {
+ // If only "from" is selected, use same value for both
+ onApply(selectedFrom, selectedFrom);
+ }
+ };
+
+ const canApply = selectedFrom !== undefined;
+
+ return (
+
+ {/* Number scroll */}
+
+ {allNumbers.map((num) => {
+ const isSelectedFrom = num === selectedFrom;
+ const isSelectedTo = num === selectedTo;
+ const isSelected = isSelectedFrom || isSelectedTo;
+ const selectable = isNumberSelectable(num);
+
+ return (
+ handleNumberPress(num)}
+ disabled={!selectable}
+ className={`h-10 w-10 items-center justify-center rounded-full ${
+ isSelected
+ ? 'bg-primary'
+ : selectable
+ ? 'border border-primary/30 bg-primary/5'
+ : 'bg-muted/30'
+ } ${selectable ? 'active:scale-95' : ''}`}
+ >
+
+ {num}
+
+
+ );
+ })}
+
+
+ {/* Selected inputs */}
+
+ {/* From input */}
+
+ {selectedFrom !== undefined ? (
+ <>
+
+ {selectedFrom}
+
+
+ >
+ ) : (
+ From
+ )}
+
+
+ β
+
+ {/* To input */}
+
+ {selectedTo !== undefined ? (
+ <>
+
+ {selectedTo}
+
+
+ >
+ ) : (
+ To
+ )}
+
+
+
+ {/* Action buttons */}
+
+
+
+
+
+ );
+}
diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx
new file mode 100644
index 000000000..342ff09ec
--- /dev/null
+++ b/components/VerseSeparator.tsx
@@ -0,0 +1,179 @@
+import {
+ AlertCircleIcon,
+ MoveVerticalIcon,
+ PencilIcon
+} from 'lucide-react-native';
+import React from 'react';
+import { Pressable, View } from 'react-native';
+import { Icon } from './ui/icon';
+import { Text } from './ui/text';
+
+interface VerseSeparatorProps {
+ from?: number;
+ to?: number;
+ label: string;
+ className?: string;
+ editable?: boolean;
+ largeText?: boolean;
+ onPress?: () => void;
+ // Selection for recording: clicking the separator text selects it for recording
+ isSelectedForRecording?: boolean;
+ onSelectForRecording?: () => void;
+ dragHandleComponent?: React.ComponentType<{
+ mode?: 'fixed-order' | 'draggable';
+ children?: React.ReactNode;
+ }>;
+ dragHandleProps?: {
+ mode?: 'fixed-order' | 'draggable';
+ };
+}
+
+export function VerseSeparator({
+ from,
+ to,
+ label,
+ className = '',
+ editable = false,
+ largeText = false,
+ onPress,
+ isSelectedForRecording = false,
+ onSelectForRecording,
+ dragHandleComponent: DragHandleComponent,
+ dragHandleProps
+}: VerseSeparatorProps) {
+ const hasNumbers = from !== undefined || to !== undefined;
+
+ const getText = () => {
+ // No numbers provided
+ if (!hasNumbers) {
+ return `No label assigned`;
+ }
+
+ // Only one number or both are the same
+ if (from === to || from === undefined || to === undefined) {
+ const value = from ?? to;
+ return `${label}:${value}`;
+ }
+
+ // Range of numbers
+ return `${label}:${from}-${to}`;
+ };
+
+ if (!hasNumbers) {
+ // No assigned - warning style with amber/orange tones
+ // Background changes when selected for recording
+ const unassignedBgClass = isSelectedForRecording
+ ? 'border-primary bg-primary/20'
+ : 'border-amber-500/30 bg-amber-500/10';
+
+ return (
+
+
+
+
+ {/* Text is clickable for recording selection when onSelectForRecording is provided */}
+ {onSelectForRecording ? (
+
+
+ {getText()}
+
+
+ ) : (
+
+ {getText()}
+
+ )}
+
+
+
+ );
+ }
+
+ // Has numbers - pill style
+ // Background changes when selected for recording
+ const pillBgClass = isSelectedForRecording
+ ? 'bg-primary/30 border border-primary'
+ : 'bg-primary/10';
+
+ const pillContent = (
+
+ {DragHandleComponent && editable && (
+
+
+
+ )}
+ {/* Text is clickable for recording selection when onSelectForRecording is provided */}
+ {onSelectForRecording ? (
+
+
+ {getText()}
+
+
+ ) : (
+
+ {getText()}
+
+ )}
+ {/* Edit icon - only shown when editable and onPress is provided */}
+ {editable && onPress && (
+
+
+
+ )}
+
+ );
+
+ return (
+
+
+ {DragHandleComponent && editable ? (
+
+ {pillContent}
+
+ ) : (
+ pillContent
+ )}
+
+
+ );
+}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
index e61418e0f..17bdb13f8 100644
--- a/components/ui/select.tsx
+++ b/components/ui/select.tsx
@@ -172,27 +172,33 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
SelectPrimitive.ItemRef,
- SelectPrimitive.ItemProps & { textClassName?: string }
->(({ className, textClassName, ...props }, ref) => (
+ SelectPrimitive.ItemProps & {
+ textClassName?: string;
+ showCheckIcon?: boolean;
+ }
+>(({ className, textClassName, showCheckIcon = true, ...props }, ref) => (
-
-
-
-
-
+ {showCheckIcon && (
+
+
+
+
+
+ )}
{
+ try {
+ const assetLocalTable = resolveTable('asset', { localOverride: true });
+
+ // Verify this asset exists in the LOCAL table
+ const localAsset = await system.db
+ .select()
+ .from(assetLocalTable)
+ .where(eq(assetLocalTable.id, assetId))
+ .limit(1);
+
+ if (!localAsset || localAsset.length === 0) {
+ throw new Error(
+ 'Asset not found in local table - cannot update synced assets'
+ );
+ }
+
+ // Verify it doesn't exist in synced table (double-check it's not published)
+ const syncedTable = resolveTable('asset', { localOverride: false });
+ const syncedAsset = await system.db
+ .select()
+ .from(syncedTable)
+ .where(eq(syncedTable.id, assetId))
+ .limit(1);
+
+ if (syncedAsset && syncedAsset.length > 0) {
+ throw new Error(
+ 'Cannot update synced assets - they are immutable once published'
+ );
+ }
+
+ // Safe to update - it's local only
+ const metadataStr = metadata ? JSON.stringify(metadata) : null;
+ await system.db
+ .update(assetLocalTable)
+ .set({ metadata: metadataStr })
+ .where(eq(assetLocalTable.id, assetId));
+
+ console.log(`β
Asset ${assetId.slice(0, 8)} metadata updated`);
+ } catch (error) {
+ console.error('Failed to update asset metadata:', error);
+ throw error;
+ }
+}
+
+/**
+ * Asset update payload for batch operations
+ */
+export interface AssetUpdatePayload {
+ assetId: string;
+ metadata?: AssetMetadata | null;
+ order_index?: number;
+}
+
+/**
+ * Batch update asset metadata and/or order_index for multiple assets
+ * @param updates - Array of { assetId, metadata?, order_index? } objects
+ */
+export async function batchUpdateAssetMetadata(
+ updates: AssetUpdatePayload[]
+): Promise {
+ if (updates.length === 0) return;
+
+ try {
+ const assetLocalTable = resolveTable('asset', { localOverride: true });
+ const syncedTable = resolveTable('asset', { localOverride: false });
+
+ const assetIds = updates.map((u) => u.assetId);
+
+ // Check which assets exist in synced table (immutable)
+ const syncedAssets = await system.db
+ .select({ id: syncedTable.id })
+ .from(syncedTable)
+ .where(inArray(syncedTable.id, assetIds));
+
+ const syncedIds = new Set(syncedAssets.map((a) => a.id));
+
+ // Filter out synced assets
+ const localUpdates = updates.filter((u) => !syncedIds.has(u.assetId));
+
+ if (localUpdates.length === 0) {
+ console.log('No local assets to update');
+ return;
+ }
+
+ // Update each local asset
+ for (const update of localUpdates) {
+ const setPayload: { metadata?: string | null; order_index?: number } = {};
+
+ // Only include metadata if explicitly provided
+ if (update.metadata !== undefined) {
+ setPayload.metadata = update.metadata
+ ? JSON.stringify(update.metadata)
+ : null;
+ }
+
+ // Only include order_index if explicitly provided
+ if (update.order_index !== undefined) {
+ setPayload.order_index = update.order_index;
+ }
+
+ // Skip if nothing to update
+ if (Object.keys(setPayload).length === 0) continue;
+
+ await system.db
+ .update(assetLocalTable)
+ .set(setPayload)
+ .where(eq(assetLocalTable.id, update.assetId));
+ }
+
+ console.log(`β
Updated ${localUpdates.length} assets`);
+ } catch (error) {
+ console.error('Failed to batch update assets:', error);
+ throw error;
+ }
+}
diff --git a/database_services/tagCache.ts b/database_services/tagCache.ts
new file mode 100644
index 000000000..acc1d018b
--- /dev/null
+++ b/database_services/tagCache.ts
@@ -0,0 +1,8 @@
+// export type Tag = typeof tag.$inferSelect;
+export interface Tag {
+ id: string;
+ key: string;
+ value?: string;
+}
+
+export const tagCache = new Map();
diff --git a/database_services/tagService.ts b/database_services/tagService.ts
index 8d4f54bc9..3c5136420 100644
--- a/database_services/tagService.ts
+++ b/database_services/tagService.ts
@@ -1,6 +1,8 @@
-import { eq } from 'drizzle-orm';
+import { resolveTable } from '@/utils/dbUtils';
+import { and, asc, eq, like } from 'drizzle-orm';
import { asset_tag_link, quest_tag_link, tag } from '../db/drizzleSchema';
import { system } from '../db/powersync/system';
+import { tagCache } from './tagCache';
export type Tag = typeof tag.$inferSelect;
@@ -48,6 +50,109 @@ export class TagService {
return Promise.all(tagPromises);
}
+
+ // async getTagsByTagKey(tagKey: string, limit = 200) {
+ // // First get tag IDs from junction table
+ // const tags = await db
+ // .select()
+ // .from(tag)
+ // .where(eq(tag.key, tagKey))
+ // .orderBy(asc(tag.value))
+ // .limit(limit);
+
+ // return tags;
+ // }
+
+ async searchTags(searchTerm?: string, limit = 200) {
+ const whereCondition = searchTerm
+ ? and(eq(tag.active, true), like(tag.key, `${searchTerm}%`))
+ : eq(tag.active, true);
+ const tags = await db
+ .select()
+ .from(tag)
+ .where(whereCondition)
+ .orderBy(asc(tag.key), asc(tag.value))
+ .limit(limit);
+
+ return tags;
+ }
+
+ async getAllActiveTags(limit = 200) {
+ const tags = await db
+ .select()
+ .from(tag)
+ .where(eq(tag.active, true))
+ .orderBy(asc(tag.key))
+ .limit(limit);
+
+ return tags;
+ }
+
+ async preloadTagsIntoCache() {
+ const tags = await db
+ .select({ id: tag.id, key: tag.key, value: tag.value })
+ .from(tag)
+ .where(eq(tag.active, true))
+ // .orderBy(asc(tag.key), asc(tag.value))
+ .limit(20000);
+
+ for (const tagRecord of tags) {
+ tagCache.set(tagRecord.id, tagRecord);
+ }
+ }
+
+ /**
+ * Assigns a list of tags to an asset.
+ * Deletes all existing tag assignments for the asset and creates new ones.
+ * @param asset_id The ID of the asset
+ * @param tag_ids Array of tag IDs to assign to the asset
+ */
+ async assignTagsToAssetLocal(asset_id: string, tag_ids: string[]) {
+ try {
+ // Start a transaction to ensure atomicity
+ const result = await db.transaction(async (tx) => {
+ const contentLocal = resolveTable('asset_tag_link', {
+ localOverride: true
+ });
+ // 1. Delete all existing tag assignments for this asset
+ await tx
+ .delete(contentLocal)
+ .where(eq(contentLocal.asset_id, asset_id));
+
+ // 2. Insert new tag assignments if any tag IDs are provided
+ if (tag_ids.length > 0) {
+ const newAssignments = tag_ids.map((tag_id) => ({
+ asset_id,
+ tag_id
+ }));
+
+ const newAssignmentsResult = await tx
+ .insert(contentLocal)
+ .values(newAssignments)
+ .returning();
+ console.log(
+ `[TagService] New assignments result:`,
+ newAssignmentsResult
+ );
+ }
+
+ return { success: true, assigned_count: tag_ids.length };
+ });
+
+ console.log(
+ `[TagService] Successfully assigned ${tag_ids.length} tags to asset ${asset_id}`
+ );
+ return result;
+ } catch (error) {
+ console.error(
+ `[TagService] Failed to assign tags to asset ${asset_id}:`,
+ error
+ );
+ throw new Error(
+ `Failed to assign tags to asset: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ }
}
export const tagService = new TagService();
diff --git a/db/drizzleSchemaColumns.ts b/db/drizzleSchemaColumns.ts
index 3123d623e..809a05edf 100644
--- a/db/drizzleSchemaColumns.ts
+++ b/db/drizzleSchemaColumns.ts
@@ -359,6 +359,7 @@ export function createAssetTable<
content_type: text({ enum: contentTypeOptions }).default('source'),
creator_id: text().references(() => profile.id),
order_index: int().notNull().default(0),
+ metadata: text(), // JSON metadata for asset-specific data (e.g., verse range)
...extraColumns
},
(table) => {
diff --git a/hooks/db/useAssets.ts b/hooks/db/useAssets.ts
index 663301fc1..212e0b8e0 100644
--- a/hooks/db/useAssets.ts
+++ b/hooks/db/useAssets.ts
@@ -9,7 +9,7 @@ import {
tag
} from '@/db/drizzleSchema';
import { system } from '@/db/powersync/system';
-import type { WithSource } from '@/utils/dbUtils';
+import { useNetworkStatus } from '@/hooks/useNetworkStatus';
import { blockedContentQuery, blockedUsersQuery } from '@/utils/dbUtils';
import { getOptionShowHiddenContent } from '@/utils/settingsUtils';
import {
@@ -17,6 +17,7 @@ import {
useSimpleHybridInfiniteData
} from '@/views/new/useHybridData';
import { toCompilableQuery } from '@powersync/drizzle-driver';
+import { useQuery } from '@tanstack/react-query';
import type { InferSelectModel, SQL } from 'drizzle-orm';
import {
and,
@@ -29,9 +30,10 @@ import {
isNull,
like,
notInArray,
- or
+ or,
+ sql
} from 'drizzle-orm';
-import { useMemo } from 'react';
+import React, { useMemo } from 'react';
import {
createHybridQueryConfig,
useHybridInfiniteQuery,
@@ -1032,6 +1034,7 @@ export function useAssetsQuestLinkById(
type AssetQuestLink = Asset & {
quest_active: boolean;
quest_visible: boolean;
+ tag_ids?: string[];
};
export function useAssetsByQuest(
@@ -1081,7 +1084,12 @@ export function useAssetsByQuest(
.select({
...getTableColumns(asset),
quest_visible: quest_asset_link.visible,
- quest_active: quest_asset_link.active
+ quest_active: quest_asset_link.active,
+ tag_ids: sql`(
+ SELECT json_group_array(${asset_tag_link.tag_id})
+ FROM ${asset_tag_link}
+ WHERE ${asset_tag_link.asset_id} = ${asset.id}
+ )`
})
.from(asset)
.innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id))
@@ -1094,7 +1102,34 @@ export function useAssetsByQuest(
.limit(pageSize)
.offset(offset);
- return assets;
+ // Convert tag_ids from JSON string to array for consistency with cloud query
+ const processedAssets = assets.map((asset) => {
+ let tagIds: string[] = [];
+ try {
+ if (asset.tag_ids) {
+ const parsed = JSON.parse(String(asset.tag_ids));
+ tagIds = Array.isArray(parsed) ? (parsed as string[]) : [];
+ }
+ if (asset.metadata) {
+ const parsed = JSON.parse(String(asset.metadata));
+ asset.metadata = parsed as string | null;
+ }
+ } catch (error) {
+ console.warn(
+ '[useAssetsByQuest] Failed to parse tag_ids:',
+ asset.tag_ids,
+ error
+ );
+ tagIds = [];
+ }
+
+ return {
+ ...asset,
+ tag_ids: tagIds
+ } as AssetQuestLink;
+ });
+
+ return processedAssets;
} catch (error) {
console.error('[ASSETS] Offline query error:', error);
return [];
@@ -1118,7 +1153,8 @@ export function useAssetsByQuest(
visible,
active,
asset:asset_id (
- *
+ *,
+ asset_tag_link(tag_id)
)
`
)
@@ -1146,7 +1182,8 @@ export function useAssetsByQuest(
query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`);
}
- // Order by order_index, then created_at, then name
+ // Order by created_at for cloud query
+ // Note: order_index ordering is handled client-side for cloud data
query = query.order('created_at', { ascending: true });
// Add pagination
@@ -1161,20 +1198,347 @@ export function useAssetsByQuest(
if (error) throw error;
// Map to AssetQuestLink format with quest_visible and quest_active
- const assets: AssetQuestLink[] = data
- .map((item) => {
- if (!item.asset) return null;
+ const assets: AssetQuestLink[] = data.map((item) => {
+ // Extract tag IDs from asset_tag_link array
+ const assetWithTags = item.asset as Asset & {
+ asset_tag_link?: { tag_id: string }[];
+ };
+ const tag_ids: string[] =
+ assetWithTags.asset_tag_link?.map((link) => link.tag_id) || [];
+
+ return {
+ ...item.asset,
+ quest_visible: item.visible,
+ quest_active: item.active,
+ tag_ids
+ } as AssetQuestLink;
+ });
+
+ return assets;
+ },
+ 20 // pageSize
+ );
+
+ return {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isOnline,
+ isFetching,
+ refetch
+ };
+}
+
+export function useLocalAssetsByQuest(
+ quest_id: string,
+ searchQuery: string,
+ showHiddenContent: boolean
+) {
+ const { currentUser } = useAuth();
+ const isOnline = useNetworkStatus(); // π§ Get real network status
+
+ // For local-only quests, use simple query (no pagination needed for ~200 records)
+ // This is wrapped to maintain API compatibility with infinite scroll structure
+ const simpleQuery = useQuery({
+ queryKey: ['assets', 'by-quest-local-simple', quest_id || '', searchQuery],
+ queryFn: async () => {
+ if (!quest_id || !currentUser) return [];
+
+ try {
+ const conditions = [
+ isNull(asset.source_asset_id),
+ eq(quest_asset_link.quest_id, quest_id),
+ or(
+ !showHiddenContent ? eq(asset.visible, true) : undefined,
+ eq(asset.creator_id, currentUser.id)
+ ),
+ or(
+ !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined,
+ eq(asset.creator_id, currentUser.id)
+ ),
+ notInArray(asset.id, blockedContentQuery(currentUser.id, 'asset')),
+ notInArray(asset.creator_id, blockedUsersQuery(currentUser.id)),
+ searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`))
+ ];
+
+ // Query all assets at once (no pagination for local data)
+ const assets = await system.db
+ .select({
+ ...getTableColumns(asset),
+ quest_visible: quest_asset_link.visible,
+ quest_active: quest_asset_link.active,
+ tag_ids: sql`(
+ SELECT json_group_array(${asset_tag_link.tag_id})
+ FROM ${asset_tag_link}
+ WHERE ${asset_tag_link.asset_id} = ${asset.id}
+ )`
+ })
+ .from(asset)
+ .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id))
+ .where(and(...conditions.filter(Boolean)))
+ .orderBy(
+ asc(asset.order_index),
+ asc(asset.created_at),
+ asc(asset.name)
+ );
+ // No .limit() or .offset() - fetch everything
+
+ // Process tag_ids and metadata
+ const processedAssets = assets.map((asset) => {
+ let tagIds: string[] = [];
+ try {
+ if (asset.tag_ids) {
+ const parsed = JSON.parse(String(asset.tag_ids));
+ tagIds = Array.isArray(parsed) ? (parsed as string[]) : [];
+ }
+ if (asset.metadata) {
+ const parsed = JSON.parse(String(asset.metadata));
+ asset.metadata = parsed as string | null;
+ }
+ } catch (error) {
+ console.warn(
+ '[useLocalAssetsByQuest] Failed to parse tag_ids:',
+ asset.tag_ids,
+ error
+ );
+ tagIds = [];
+ }
+
return {
- ...item.asset,
- quest_visible: item.visible,
- quest_active: item.active
+ ...asset,
+ tag_ids: tagIds
} as AssetQuestLink;
- })
- .filter((item): item is AssetQuestLink => item !== null);
+ });
+
+ return processedAssets;
+ } catch (error) {
+ console.error('[useLocalAssetsByQuest] Query error:', error);
+ return [];
+ }
+ },
+ enabled: !!quest_id && !!currentUser
+ });
+
+ // Wrap simple query result to match infinite query structure
+ // This maintains API compatibility with code expecting infinite scroll data
+ const wrappedData = React.useMemo(() => {
+ if (!simpleQuery.data) {
+ return { pages: [], pageParams: [] };
+ }
+ // Wrap all data in a single page to match InfiniteData<{ data: T[] }> structure
+ return {
+ pages: [{ data: simpleQuery.data }],
+ pageParams: [0]
+ };
+ }, [simpleQuery.data]);
+
+ // Return structure compatible with useInfiniteQuery
+ return {
+ data: wrappedData,
+ fetchNextPage: () =>
+ Promise.resolve({
+ data: wrappedData,
+ pageParam: undefined
+ }), // No-op for local data (all loaded)
+ hasNextPage: false, // All data loaded in one query
+ isFetchingNextPage: false,
+ isLoading: simpleQuery.isLoading,
+ isOnline, // π§ Use real network status (needed for publish/bookmark buttons)
+ isFetching: simpleQuery.isFetching,
+ refetch: simpleQuery.refetch
+ };
+}
+
+/**
+ * Legacy infinite scroll version (not used, kept for reference)
+ * This was the old implementation using pagination for local data
+ * Now replaced with simple query above since local data is small (~200 records)
+ */
+/*
+function _useLocalAssetsByQuestInfinite(
+ quest_id: string,
+ searchQuery: string,
+ showHiddenContent: boolean
+) {
+ const { currentUser } = useAuth();
+
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isOnline,
+ isFetching,
+ refetch
+ } = useSimpleHybridInfiniteData(
+ 'assets',
+ ['by-quest', quest_id || '', searchQuery],
+ // Offline query function - Assets must be downloaded to use
+ async ({ pageParam, pageSize }) => {
+ if (!quest_id) return [];
+
+ const limit = pageSize > 1000 ? pageSize : 1000;
+
+ try {
+ const offset = pageParam * limit;
+
+ const conditions = [
+ isNull(asset.source_asset_id),
+ eq(quest_asset_link.quest_id, quest_id),
+ or(
+ !showHiddenContent ? eq(asset.visible, true) : undefined,
+ eq(asset.creator_id, currentUser!.id)
+ ),
+ or(
+ !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined,
+ eq(asset.creator_id, currentUser!.id)
+ ),
+ notInArray(asset.id, blockedContentQuery(currentUser!.id, 'asset')),
+ notInArray(asset.creator_id, blockedUsersQuery(currentUser!.id)),
+ searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`))
+ ];
+
+ // Normal pagination without search
+ const assets = await system.db
+ .select({
+ ...getTableColumns(asset),
+ quest_visible: quest_asset_link.visible,
+ quest_active: quest_asset_link.active,
+ tag_ids: sql`(
+ SELECT json_group_array(${asset_tag_link.tag_id})
+ FROM ${asset_tag_link}
+ WHERE ${asset_tag_link.asset_id} = ${asset.id}
+ )`
+ })
+ .from(asset)
+ .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id))
+ .where(and(...conditions.filter(Boolean)))
+ .orderBy(
+ asc(asset.order_index),
+ asc(asset.created_at),
+ asc(asset.name)
+ )
+ .limit(limit)
+ .offset(offset);
+
+ // Convert tag_ids from JSON string to array for consistency with cloud query
+ const processedAssets = assets.map((asset) => {
+ let tagIds: string[] = [];
+ try {
+ if (asset.tag_ids) {
+ const parsed = JSON.parse(String(asset.tag_ids));
+ tagIds = Array.isArray(parsed) ? (parsed as string[]) : [];
+ }
+ if (asset.metadata) {
+ const parsed = JSON.parse(String(asset.metadata));
+ asset.metadata = parsed as string | null;
+ }
+ } catch (error) {
+ console.warn(
+ '[useAssetsByQuest] Failed to parse tag_ids:',
+ asset.tag_ids,
+ error
+ );
+ tagIds = [];
+ }
+
+ return {
+ ...asset,
+ tag_ids: tagIds
+ } as AssetQuestLink;
+ });
+
+ return processedAssets;
+ } catch (error) {
+ console.error('[ASSETS] Offline query error:', error);
+ return [];
+ }
+ },
+ // Cloud query function - For anonymous users, fetch assets directly from cloud
+ // For authenticated users, assets must be downloaded to use offline, but cloud query
+ // can still be used for browsing (anonymous-style access)
+ async ({ pageParam, pageSize }) => {
+ if (!quest_id) return [];
+
+ const offset = pageParam * pageSize;
+ const from = offset;
+ const to = offset + pageSize - 1;
+
+ // Build query from quest_asset_link to get both asset data and link metadata
+ let query = system.supabaseConnector.client
+ .from('quest_asset_link')
+ .select(
+ `
+ visible,
+ active,
+ asset:asset_id (
+ *,
+ asset_tag_link(tag_id)
+ )
+ `
+ )
+ .eq('quest_id', quest_id)
+ .is('asset.source_asset_id', null); // Only get original assets, not variants
+
+ // Filter by visibility - anonymous users can only see visible assets
+ // Authenticated users can see their own hidden assets if showHiddenContent is true
+ if (!showHiddenContent) {
+ // Show only visible assets
+ query = query.eq('visible', true).filter('asset.visible', 'eq', true);
+ } else if (currentUser?.id) {
+ // Show all assets, but still filter by link visibility for non-creators
+ // For creators, show all their assets even if hidden
+ query = query.or(
+ `visible.eq.true,asset.creator_id.eq.${currentUser.id}`
+ );
+ } else {
+ // Anonymous users with showHiddenContent=true still only see visible (for safety)
+ query = query.eq('visible', true).filter('asset.visible', 'eq', true);
+ }
+
+ // Add search filtering
+ if (searchQuery.trim()) {
+ query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`);
+ }
+
+ // Order by created_at for cloud query
+ // Note: order_index ordering is handled client-side for cloud data
+ query = query.order('created_at', { ascending: true });
+
+ // Add pagination
+ const { data, error } = await query.range(from, to).overrideTypes<
+ {
+ visible: boolean;
+ active: boolean;
+ asset: Asset;
+ }[]
+ >();
+
+ if (error) throw error;
+
+ // Map to AssetQuestLink format with quest_visible and quest_active
+ const assets: AssetQuestLink[] = data.map((item) => {
+ // Extract tag IDs from asset_tag_link array
+ const assetWithTags = item.asset as Asset & {
+ asset_tag_link?: { tag_id: string }[];
+ };
+ const tag_ids: string[] =
+ assetWithTags.asset_tag_link?.map((link) => link.tag_id) || [];
+
+ return {
+ ...item.asset,
+ quest_visible: item.visible,
+ quest_active: item.active,
+ tag_ids
+ } as AssetQuestLink;
+ });
return assets;
},
- 20 // pageSize
+ 1000 // pageSize
);
return {
@@ -1188,3 +1552,5 @@ export function useAssetsByQuest(
refetch
};
}
+*/
+// End of legacy infinite scroll implementation (commented out)
diff --git a/hooks/db/useSearchTags.ts b/hooks/db/useSearchTags.ts
new file mode 100644
index 000000000..2806ffdb3
--- /dev/null
+++ b/hooks/db/useSearchTags.ts
@@ -0,0 +1,32 @@
+import type { Tag } from '@/database_services/tagService';
+import { tagService } from '@/database_services/tagService';
+import { useQuery } from '@tanstack/react-query';
+
+// Re-export Tag type for convenience
+export type { Tag };
+
+/**
+ * Returns { tags, isLoading, error }
+ * Searches tags by key with optional limit
+ */
+export function useSearchTags({
+ searchTerm,
+ maxResults = 20,
+ enabled = true
+}: {
+ searchTerm?: string;
+ maxResults?: number;
+ enabled?: boolean;
+}) {
+ const {
+ data: tags,
+ isLoading: isTagsLoading,
+ ...rest
+ } = useQuery({
+ queryKey: ['tags', 'search', searchTerm, maxResults],
+ queryFn: () => tagService.searchTags(searchTerm, maxResults),
+ enabled
+ });
+
+ return { tags, isTagsLoading, ...rest };
+}
diff --git a/hooks/useAppNavigation.ts b/hooks/useAppNavigation.ts
index d6b980c79..eea33519f 100644
--- a/hooks/useAppNavigation.ts
+++ b/hooks/useAppNavigation.ts
@@ -31,7 +31,8 @@ export function useAppNavigation() {
navigationStack,
setNavigationStack,
addRecentQuest,
- addRecentAsset
+ addRecentAsset,
+ enableVerseMarkers
} = useLocalStore();
// Ensure navigationStack is always an array - safe access pattern
@@ -169,6 +170,10 @@ export function useAppNavigation() {
questData?: Record;
projectData?: Record;
}) => {
+ const assetView =
+ questData.projectData?.template === 'bible' && enableVerseMarkers
+ ? 'bible-assets'
+ : 'assets';
// Track recently visited
addRecentQuest({
id: questData.id,
@@ -180,7 +185,7 @@ export function useAppNavigation() {
// Check if we're already at this quest
if (
currentState.questId === questData.id &&
- currentState.view === 'assets'
+ currentState.view === assetView
) {
// Already here, do nothing
return;
@@ -191,11 +196,11 @@ export function useAppNavigation() {
currentState.questId === questData.id &&
currentState.view === 'asset-detail'
) {
- goBackToView('assets');
+ goBackToView(assetView);
} else {
// Navigate fresh, pass data forward and preserve bookId (for Bible navigation)
navigate({
- view: 'assets',
+ view: assetView,
questId: questData.id,
questName: questData.name,
projectId: questData.project_id,
@@ -205,7 +210,7 @@ export function useAppNavigation() {
});
}
},
- [currentState, navigate, addRecentQuest, goBackToView]
+ [currentState, navigate, addRecentQuest, goBackToView, enableVerseMarkers]
);
const goToAsset = useCallback(
@@ -329,7 +334,8 @@ export function useAppNavigation() {
goToQuest({
id: state.questId!,
project_id: state.projectId!,
- name: state.questName
+ name: state.questName,
+ projectData: state.projectData
})
});
crumbs.push({ label: state.assetName, onPress: undefined });
diff --git a/hooks/useTagStore.ts b/hooks/useTagStore.ts
new file mode 100644
index 000000000..0eb6393d2
--- /dev/null
+++ b/hooks/useTagStore.ts
@@ -0,0 +1,86 @@
+import { tag } from '@/db/drizzleSchema';
+import { system } from '@/db/powersync/system';
+import { eq, inArray } from 'drizzle-orm';
+import { create } from 'zustand';
+import type { Tag } from '../database_services/tagCache';
+import { tagCache } from '../database_services/tagCache';
+
+export type { Tag };
+
+interface TagStore {
+ getTag: (id: string) => Tag | undefined;
+ getManyTags: (ids: string[]) => Tag[];
+ fetchTag: (id: string) => Promise;
+ fetchManyTags: (ids: string[]) => Promise;
+}
+
+export const useTagStore = create(() => ({
+ // Sync version - uses cache only
+ getTag: (id) => tagCache.get(id),
+
+ // Sync version - uses cache only
+ getManyTags: (ids) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!ids || ids.length === 0) return [];
+
+ return ids.map((id) => tagCache.get(id)).filter(Boolean);
+ },
+
+ // Async version - fetches from database
+ fetchTag: async (id) => {
+ // Check cache first
+ const cached = tagCache.get(id);
+ if (cached) return cached;
+
+ // Fetch from database
+ const results = await system.db
+ .select({ id: tag.id, key: tag.key, value: tag.value })
+ .from(tag)
+ .where(eq(tag.id, id))
+ .limit(1);
+
+ const result = results[0];
+ if (result) {
+ // Store in cache for future use
+ tagCache.set(result.id, result);
+ }
+ return result;
+ },
+
+ // Async version - fetches from database
+ fetchManyTags: async (ids) => {
+ if (ids.length === 0) return [];
+
+ // Filter out IDs already in cache
+ const cachedTags: Tag[] = [];
+ const missingIds: string[] = [];
+
+ for (const id of ids) {
+ const cached = tagCache.get(id);
+ if (cached) {
+ cachedTags.push(cached);
+ } else {
+ missingIds.push(id);
+ }
+ }
+
+ // If all tags are cached, return them
+ if (missingIds.length === 0) {
+ return cachedTags;
+ }
+
+ // Fetch missing tags from database
+ const fetchedTags = await system.db
+ .select({ id: tag.id, key: tag.key, value: tag.value })
+ .from(tag)
+ .where(inArray(tag.id, missingIds));
+
+ // Store fetched tags in cache
+ for (const fetchedTag of fetchedTags) {
+ tagCache.set(fetchedTag.id, fetchedTag);
+ }
+
+ // Return all tags (cached + fetched)
+ return [...cachedTags, ...fetchedTags];
+ }
+}));
diff --git a/package-lock.json b/package-lock.json
index a70d0bf69..f97d91321 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -97,6 +97,7 @@
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
+ "react-native-sortables": "^1.9.4",
"react-native-svg": "^15.11.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
@@ -23202,6 +23203,19 @@
"react-native": "*"
}
},
+ "node_modules/react-native-haptic-feedback": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-2.3.3.tgz",
+ "integrity": "sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "example"
+ ],
+ "peerDependencies": {
+ "react-native": ">=0.60.0"
+ }
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
@@ -23247,9 +23261,9 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.0.tgz",
- "integrity": "sha512-L8FqZn8VjZyBaCUMYFyx1Y+T+ZTbblaudpxReOXJ66RnOf52g6UM4Pa/IjwLD1XAw1FUxLRQrtpdjbkEc74FiQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz",
+ "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
@@ -23299,6 +23313,21 @@
"react-native": "*"
}
},
+ "node_modules/react-native-sortables": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/react-native-sortables/-/react-native-sortables-1.9.4.tgz",
+ "integrity": "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "react-native-haptic-feedback": ">=2.0.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-gesture-handler": ">=2.0.0",
+ "react-native-reanimated": ">=3.0.0"
+ }
+ },
"node_modules/react-native-svg": {
"version": "15.12.1",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
diff --git a/package.json b/package.json
index ad1f72778..c4b2f78d1 100644
--- a/package.json
+++ b/package.json
@@ -142,6 +142,7 @@
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
+ "react-native-sortables": "^1.9.4",
"react-native-svg": "^15.11.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
diff --git a/services/localizations.ts b/services/localizations.ts
index 7e807a428..aee3fe518 100644
--- a/services/localizations.ts
+++ b/services/localizations.ts
@@ -3444,6 +3444,48 @@ export const localizations = {
tok_pisin: 'Recording...',
indonesian: 'Merekam...'
},
+ recordTo: {
+ english: 'Record to',
+ spanish: 'Grabar en',
+ brazilian_portuguese: 'Gravar em',
+ tok_pisin: 'Rekodem long',
+ indonesian: 'Rekam ke'
+ },
+ noLabelSelected: {
+ english: 'No label selected',
+ spanish: 'Sin etiqueta seleccionada',
+ brazilian_portuguese: 'Nenhum rΓ³tulo selecionado',
+ tok_pisin: 'No label i stap',
+ indonesian: 'Tidak ada label dipilih'
+ },
+ startRecordingSession: {
+ english: 'Start Recording Session',
+ spanish: 'Iniciar SesiΓ³n de GrabaciΓ³n',
+ brazilian_portuguese: 'Iniciar SessΓ£o de GravaΓ§Γ£o',
+ tok_pisin: 'Stat Rekodem Taim',
+ indonesian: 'Mulai Sesi Rekaman'
+ },
+ typeToConfirm: {
+ english: 'Type {text} to confirm',
+ spanish: 'Escriba {text} para confirmar',
+ brazilian_portuguese: 'Digite {text} para confirmar',
+ tok_pisin: 'Raitim {text} bilong siaim',
+ indonesian: 'Ketik {text} untuk mengkonfirmasi'
+ },
+ confirmDeletion: {
+ english: 'Confirm Deletion',
+ spanish: 'Confirmar EliminaciΓ³n',
+ brazilian_portuguese: 'Confirmar ExclusΓ£o',
+ tok_pisin: 'Siaim Rausim',
+ indonesian: 'Konfirmasi Penghapusan'
+ },
+ deleting: {
+ english: 'Deleting...',
+ spanish: 'Eliminando...',
+ brazilian_portuguese: 'Excluindo...',
+ tok_pisin: 'Rausim nau...',
+ indonesian: 'Menghapus...'
+ },
audioSegments: {
english: 'Audio Segments',
spanish: 'Pistas de Audio',
@@ -6110,7 +6152,23 @@ export const localizations = {
tok_pisin: 'Link kopim igo long clipboard!',
indonesian: 'Tautan disalin ke clipboard!'
},
-
+ verseMarkers: {
+ english: 'Verse Labels',
+ spanish: 'Etiquetas de VersΓculos',
+ brazilian_portuguese: 'Etiquetas de VersΓculos',
+ tok_pisin: 'Verse Labels',
+ indonesian: 'Label Versi'
+ },
+ verseMarkersDescription: {
+ english: 'Enable verse labels to help organize Bible resources',
+ spanish:
+ 'Habilitar etiquetas de versΓculos para ayudar a organizar recursos de la Biblia',
+ brazilian_portuguese:
+ 'Habilitar etiquetas de versΓculos para ajudar a organizar recursos da BΓblia',
+ tok_pisin: 'Enable verse labels to help organize Bible resources',
+ indonesian:
+ 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab'
+ },
// Languoid Link Suggestion strings
languoidLinkSuggestionTitle: {
english: 'Link your language?',
diff --git a/store/localStore.ts b/store/localStore.ts
index b396f09d4..8fc92b4e8 100644
--- a/store/localStore.ts
+++ b/store/localStore.ts
@@ -11,6 +11,7 @@ export type AppView =
| 'quests'
| 'assets'
| 'asset-detail'
+ | 'bible-assets'
| 'profile'
| 'notifications'
| 'settings'
@@ -120,6 +121,10 @@ export interface LocalState {
setEnablePlayAll: (enabled: boolean) => void;
enableQuestExport: boolean;
setEnableQuestExport: (enabled: boolean) => void;
+ enableVerseMarkers: boolean;
+ setEnableVerseMarkers: (enabled: boolean) => void;
+ verseMarkersFeaturePrompted: boolean;
+ setVerseMarkersFeaturePrompted: (prompted: boolean) => void;
enableTranscription: boolean;
setEnableTranscription: (enabled: boolean) => void;
enableLanguoidLinkSuggestions: boolean;
@@ -261,6 +266,8 @@ export const useLocalStore = create()(
enableAiSuggestions: false,
enablePlayAll: false,
enableQuestExport: false,
+ enableVerseMarkers: false,
+ verseMarkersFeaturePrompted: false,
enableTranscription: false,
enableLanguoidLinkSuggestions: false,
@@ -369,6 +376,9 @@ export const useLocalStore = create()(
set({ enableAiSuggestions: enabled }),
setEnablePlayAll: (enabled) => set({ enablePlayAll: enabled }),
setEnableQuestExport: (enabled) => set({ enableQuestExport: enabled }),
+ setEnableVerseMarkers: (enabled) => set({ enableVerseMarkers: enabled }),
+ setVerseMarkersFeaturePrompted: (prompted) =>
+ set({ verseMarkersFeaturePrompted: prompted }),
setEnableTranscription: (enabled) =>
set({ enableTranscription: enabled }),
setEnableLanguoidLinkSuggestions: (enabled) =>
diff --git a/supabase/migrations/20251214120000_add_asset_metadata_field.sql b/supabase/migrations/20251214120000_add_asset_metadata_field.sql
new file mode 100644
index 000000000..b8686a0a9
--- /dev/null
+++ b/supabase/migrations/20251214120000_add_asset_metadata_field.sql
@@ -0,0 +1,11 @@
+-- Migration: Add metadata field to asset table
+-- Version: 2.0 β 2.1
+-- Purpose: Store JSON metadata for asset-specific data (e.g., verse ranges for Bible projects)
+
+-- Add metadata column to asset table
+alter table asset
+ add column if not exists metadata text;
+
+-- Add comment describing the field
+comment on column asset.metadata is 'JSON metadata for asset-specific data (e.g., {"verse": {"from": 1, "to": 3}})';
+
diff --git a/views/AppView.tsx b/views/AppView.tsx
index fae03eb51..82f7f15da 100644
--- a/views/AppView.tsx
+++ b/views/AppView.tsx
@@ -34,6 +34,7 @@ const NextGenAssetDetailView = React.lazy(
const NextGenAssetsView = React.lazy(
() => import('@/views/new/NextGenAssetsView')
);
+const BibleAssetsView = React.lazy(() => import('@/views/new/BibleAssetsView'));
const NextGenProjectsView = React.lazy(
() => import('@/views/new/NextGenProjectsView')
);
@@ -63,7 +64,8 @@ import { useLocalStore } from '@/store/localStore';
// import { OTAUpdateDebugControls } from '@/components/OTAUpdateDebugControls';
function AppViewContent() {
- const { currentView, canGoBack, goBack, goToProjects } = useAppNavigation();
+ const { currentView, canGoBack, goBack, goToProjects, goBackToView } =
+ useAppNavigation();
const { isAuthenticated } = useAuth();
const authView = useLocalStore((state) => state.authView);
const setAuthView = useLocalStore((state) => state.setAuthView);
@@ -76,6 +78,7 @@ function AppViewContent() {
const setOnboardingIsOpen = useLocalStore(
(state) => state.setOnboardingIsOpen
);
+ const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers);
const [drawerIsVisible, setDrawerIsVisible] = useState(false);
const [deferredView, setDeferredView] = useState(currentView);
const { isCloudLoading } = useCloudLoading();
@@ -162,6 +165,20 @@ function AppViewContent() {
}
}, [currentView, isAuthenticated, goToProjects]);
+ // Block bible-assets view if enableVerseMarkers is disabled
+ // Redirect to previous view if user tries to access bible-assets without the feature enabled
+ useEffect(() => {
+ if (currentView === 'bible-assets' && !enableVerseMarkers) {
+ // Redirect to previous view (usually quests or assets)
+ if (canGoBack) {
+ goBack();
+ } else {
+ // Fallback to projects if no navigation history
+ goToProjects();
+ }
+ }
+ }, [currentView, enableVerseMarkers, canGoBack, goBack, goToProjects]);
+
// Track if navigation is in progress
const isNavigating = currentView !== deferredView;
@@ -210,6 +227,8 @@ function AppViewContent() {
return ;
case 'assets':
return ;
+ case 'bible-assets':
+ return ;
case 'asset-detail':
return ;
case 'profile':
diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx
index 36eb0ec4f..f8ec69487 100644
--- a/views/SettingsView.tsx
+++ b/views/SettingsView.tsx
@@ -53,6 +53,7 @@ export default function SettingsView() {
);
const enablePlayAll = useLocalStore((state) => state.enablePlayAll);
const enableQuestExport = useLocalStore((state) => state.enableQuestExport);
+ const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers);
const enableTranscription = useLocalStore(
(state) => state.enableTranscription
);
@@ -78,6 +79,9 @@ export default function SettingsView() {
const setEnableQuestExport = useLocalStore(
(state) => state.setEnableQuestExport
);
+ const setEnableVerseMarkers = useLocalStore(
+ (state) => state.setEnableVerseMarkers
+ );
const setEnableTranscription = useLocalStore(
(state) => state.setEnableTranscription
);
@@ -127,6 +131,10 @@ export default function SettingsView() {
console.log('Quest export:', value);
};
+ const handleVerseMarkersToggle = (value: boolean) => {
+ setEnableVerseMarkers(value);
+ }
+
const handleTranscriptionToggle = (value: boolean) => {
setEnableTranscription(value);
};
@@ -284,6 +292,15 @@ export default function SettingsView() {
disabled: !isOnline
},
{
+ id: 'verseMarkers',
+ title: t('verseMarkers') || 'Verse Labels',
+ description:
+ t('verseMarkersDescription') ||
+ 'Enable verse labels to help organize Bible resources',
+ type: 'toggle',
+ value: enableVerseMarkers,
+ onPress: () => handleVerseMarkersToggle(!enableVerseMarkers)
+ },{
id: 'transcription',
title: t('transcription') || 'Transcription',
description:
diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx
index a4f4975b9..94d0e1964 100644
--- a/views/new/AssetListItem.tsx
+++ b/views/new/AssetListItem.tsx
@@ -8,14 +8,24 @@ import {
import { Icon } from '@/components/ui/icon';
import { useAuth } from '@/contexts/AuthContext';
import { LayerType, useStatusContext } from '@/contexts/StatusContext';
+// import type { Tag } from '@/database_services/tagCache';
+// import { tagService } from '@/database_services/tagService';
import type { asset as asset_type } from '@/db/drizzleSchema';
import { useAppNavigation } from '@/hooks/useAppNavigation';
import { useLocalization } from '@/hooks/useLocalization';
+// import { useTagStore } from '@/hooks/useTagStore';
import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
import type { AttachmentRecord } from '@powersync/attachments';
-import { EyeOffIcon, HardDriveIcon, PauseIcon } from 'lucide-react-native';
+import {
+ EyeOffIcon,
+ HardDriveIcon,
+ PauseIcon
+ // Plus,
+ // TagIcon
+} from 'lucide-react-native';
import React from 'react';
import { Pressable, View } from 'react-native';
+// import { TagModal } from '../../components/TagModal';
import { useItemDownload, useItemDownloadStatus } from './useHybridData';
// Define props locally to avoid require cycle
@@ -25,10 +35,13 @@ type Asset = typeof asset_type.$inferSelect;
type AssetQuestLink = Asset & {
quest_active: boolean;
quest_visible: boolean;
+ // tag_ids?: string[] | undefined;
};
export interface AssetListItemProps {
asset: AssetQuestLink;
+ isPublished: boolean;
questId: string;
+ onUpdate?: () => void;
attachmentState?: AttachmentRecord;
isCurrentlyPlaying?: boolean;
}
@@ -36,8 +49,10 @@ export interface AssetListItemProps {
export const AssetListItem: React.FC = ({
asset,
questId,
- attachmentState,
- isCurrentlyPlaying = false
+ isCurrentlyPlaying = false,
+ isPublished: _isPublished,
+ onUpdate: _onUpdate,
+ attachmentState: _attachmentState
}) => {
const { goToAsset, currentProjectData, currentQuestData } =
useAppNavigation();
@@ -46,12 +61,57 @@ export const AssetListItem: React.FC = ({
// Check if asset is downloaded
const isDownloaded = useItemDownloadStatus(asset, currentUser?.id);
+ // Tags temporarily disabled
+ // const fetchManyTags = useTagStore((s) => s.fetchManyTags);
+ // const [tags, setTags] = React.useState<
+ // { id: string; key: string; value?: string }[]
+ // >([]);
+
+ // React.useEffect(() => {
+ // const loadTags = async () => {
+ // if (asset.tag_ids && asset.tag_ids.length > 0) {
+ // const fetchedTags = await fetchManyTags(asset.tag_ids);
+ // setTags(fetchedTags);
+ // }
+ // };
+ // void loadTags();
+ // }, [asset.tag_ids, fetchManyTags]);
+
// Download mutation
const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload(
'asset',
asset.id
);
+ // Tag modal state - temporarily disabled
+ // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false);
+
+ // const handleOpenTagModal = () => {
+ // console.log('Opening tag modal for asset:', asset.id);
+ // setIsTagModalVisible(true);
+ // };
+
+ // const handleAssignTags = async (tags: Tag[]) => {
+ // try {
+ // // Extract tag IDs from the tags array
+ // const tagIds = tags.map((tag) => tag.id);
+
+ // // Use the tagService to assign tags to the asset
+ // await tagService.assignTagsToAssetLocal(asset.id, tagIds);
+
+ // onUpdate?.();
+
+ // console.log(
+ // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}`
+ // );
+ // } catch (error) {
+ // console.error('Failed to assign tags to asset:', error);
+ // // TODO: Show error toast/alert to user
+ // } finally {
+ // setIsTagModalVisible(false);
+ // }
+ // };
+
const layerStatus = useStatusContext();
const { allowEditing, invisible } = layerStatus.getStatusParams(
LayerType.ASSET,
@@ -97,6 +157,8 @@ export const AssetListItem: React.FC = ({
downloadAsset({ userId: currentUser.id, download: !isDownloaded });
};
+ // const tag = tags.length > 0 ? tags[0] : null;
+
return (
= ({
>
-
-
+
+
{(!allowEditing || invisible) && (
{invisible && (
@@ -129,6 +191,33 @@ export const AssetListItem: React.FC = ({
+ {/* Tags temporarily disabled */}
+ {/*
+
+ {tags.length === 0 ? (
+ !isPublished && (
+
+
+
+
+ )
+ ) : (
+
+
+
+
+ {tag && `${tag.key}${tag.value && `: ${tag.value}`}`}
+
+
+
+ )}
+
+ */}
= ({
*/}
+
+ {/* Tags temporarily disabled */}
+ {/* setIsTagModalVisible(false)}
+ onAssignTags={handleAssignTags}
+ /> */}
);
};
diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx
new file mode 100644
index 000000000..f4ebcc190
--- /dev/null
+++ b/views/new/BibleAssetListItem.tsx
@@ -0,0 +1,460 @@
+import { DownloadIndicator } from '@/components/DownloadIndicator';
+// import { Badge } from '@/components/ui/badge';
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle
+} from '@/components/ui/card';
+import { Icon } from '@/components/ui/icon';
+import { useAuth } from '@/contexts/AuthContext';
+import { LayerType, useStatusContext } from '@/contexts/StatusContext';
+// import type { Tag } from '@/database_services/tagCache';
+// import { tagService } from '@/database_services/tagService';
+import type { asset as asset_type } from '@/db/drizzleSchema';
+import { useAppNavigation } from '@/hooks/useAppNavigation';
+import { useLocalization } from '@/hooks/useLocalization';
+// import { useTagStore } from '@/hooks/useTagStore';
+import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
+import type { AttachmentRecord } from '@powersync/attachments';
+import {
+ CheckSquareIcon,
+ EyeOffIcon,
+ GripVerticalIcon,
+ HardDriveIcon,
+ PauseIcon,
+ PencilLineIcon,
+ PlayIcon,
+ // Plus,
+ SquareIcon
+ // TagIcon
+} from 'lucide-react-native';
+import React from 'react';
+import { Pressable, View } from 'react-native';
+import Sortable from 'react-native-sortables';
+// import { TagModal } from '../../components/TagModal';
+import { useItemDownload, useItemDownloadStatus } from './useHybridData';
+
+// Define props locally to avoid require cycle
+
+type Asset = typeof asset_type.$inferSelect;
+
+type AssetQuestLink = Asset & {
+ quest_active: boolean;
+ quest_visible: boolean;
+ tag_ids?: string[] | undefined;
+};
+export interface BibleAssetListItemProps {
+ asset: AssetQuestLink;
+ isPublished: boolean;
+ questId: string;
+ onUpdate?: () => void;
+ onPlay?: (assetId: string) => void | Promise;
+ attachmentState?: AttachmentRecord;
+ isCurrentlyPlaying?: boolean;
+ // Drag & Drop props (replaces dragHandle ReactNode)
+ showDragHandle?: boolean; // Whether to show drag handle (not in selection mode, not published)
+ isDragFixed?: boolean; // Whether this item has fixed drag order
+ // Selection mode props (batch operations like merge/delete)
+ isSelectionMode?: boolean;
+ isSelected?: boolean;
+ onToggleSelect?: (assetId: string) => void;
+ onEnterSelection?: (assetId: string) => void;
+ // Recording insertion point selection
+ isSelectedForRecording?: boolean;
+ onSelectForRecording?: (assetId: string) => void;
+ // Rename asset
+ onRename?: (assetId: string, currentName: string | null) => void;
+}
+
+const BibleAssetListItemComponent: React.FC = ({
+ asset,
+ questId,
+ isCurrentlyPlaying = false,
+ isPublished,
+ onUpdate: _onUpdate,
+ onPlay,
+ attachmentState: _attachmentState,
+ showDragHandle = false,
+ isDragFixed = false,
+ isSelectionMode = false,
+ isSelected = false,
+ onToggleSelect,
+ onEnterSelection,
+ isSelectedForRecording = false,
+ onSelectForRecording,
+ onRename
+}) => {
+ const { goToAsset, currentProjectData, currentQuestData } =
+ useAppNavigation();
+ const { currentUser } = useAuth();
+ const { t } = useLocalization();
+ // Check if asset is downloaded
+ const isDownloaded = useItemDownloadStatus(asset, currentUser?.id);
+
+ // Tags functionality commented out
+ // const fetchManyTags = useTagStore((s) => s.fetchManyTags);
+ // const [tags, setTags] = React.useState<
+ // { id: string; key: string; value?: string }[]
+ // >([]);
+
+ // React.useEffect(() => {
+ // const loadTags = async () => {
+ // if (asset.tag_ids && asset.tag_ids.length > 0) {
+ // const fetchedTags = await fetchManyTags(asset.tag_ids);
+ // setTags(fetchedTags);
+ // }
+ // };
+ // void loadTags();
+ // }, [asset.tag_ids, fetchManyTags]);
+
+ // Download mutation
+ const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload(
+ 'asset',
+ asset.id
+ );
+
+ // Tag modal state - commented out
+ // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false);
+
+ // const handleOpenTagModal = () => {
+ // console.log('Opening tag modal for asset:', asset.id);
+ // setIsTagModalVisible(true);
+ // };
+
+ // const handleAssignTags = async (tags: Tag[]) => {
+ // try {
+ // // Extract tag IDs from the tags array
+ // const tagIds = tags.map((tag) => tag.id);
+
+ // // Use the tagService to assign tags to the asset
+ // await tagService.assignTagsToAssetLocal(asset.id, tagIds);
+
+ // onUpdate?.();
+
+ // console.log(
+ // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}`
+ // );
+ // } catch (error) {
+ // console.error('Failed to assign tags to asset:', error);
+ // // TODO: Show error toast/alert to user
+ // } finally {
+ // setIsTagModalVisible(false);
+ // }
+ // };
+
+ const layerStatus = useStatusContext();
+ const { allowEditing, invisible } = layerStatus.getStatusParams(
+ LayerType.ASSET,
+ asset.id || '',
+ {
+ visible: asset.visible && asset.quest_visible,
+ active: asset.active && asset.quest_active,
+ source: asset.source
+ },
+ questId
+ );
+
+ const handlePress = () => {
+ // If in selection mode, toggle selection instead of navigating
+ if (isSelectionMode) {
+ onToggleSelect?.(asset.id);
+ return;
+ }
+
+ // If not published, select for recording (toggle)
+ if (!isPublished) {
+ onSelectForRecording?.(asset.id);
+ return;
+ }
+
+ layerStatus.setLayerStatus(
+ LayerType.ASSET,
+ {
+ visible: asset.visible,
+ active: asset.active,
+ quest_active: asset.quest_active,
+ quest_visible: asset.quest_visible,
+ source: asset.source
+ },
+ asset.id,
+ questId
+ );
+
+ goToAsset({
+ id: asset.id,
+ name: asset.name || t('unnamedAsset'),
+ questId: questId,
+ projectId: asset.project_id!,
+ projectData: currentProjectData, // Pass project data forward!
+ questData: currentQuestData // Pass quest data forward!
+ // NOTE: Don't pass assetData - the detail view needs full asset with content/audio
+ // relationships which aren't loaded in the list view
+ });
+ };
+
+ const handleLongPress = () => {
+ // Enter selection mode on long press
+ if (!isSelectionMode && onEnterSelection) {
+ onEnterSelection(asset.id);
+ }
+ };
+
+ const handleDownloadToggle = () => {
+ if (!currentUser?.id) return;
+
+ // Toggle download status
+ downloadAsset({ userId: currentUser.id, download: !isDownloaded });
+ };
+
+ // Tags display - commented out
+ // const tag = tags.length > 0 ? tags[0] : null;
+
+ // Create drag handle inside component (memoized for performance)
+ const dragHandle = React.useMemo(() => {
+ if (!showDragHandle) return null;
+
+ return (
+
+
+
+ );
+ }, [showDragHandle, isDragFixed]);
+
+ // Render selection checkbox or drag handle
+ const selectionOrDragElement = isSelectionMode ? (
+ onToggleSelect?.(asset.id)}
+ className="mr-1 flex h-7 w-7 items-center justify-center"
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+
+
+ ) : (
+ dragHandle
+ );
+
+ return (
+
+
+
+
+
+
+ {(!allowEditing || invisible) && (
+
+ {invisible && (
+
+ )}
+ {!allowEditing && (
+
+ )}
+
+ )}
+ {selectionOrDragElement}
+
+ {asset.source === 'local' && (
+
+ )}
+ {/* Play button - only show if onPlay is provided */}
+ {onPlay && (
+ {
+ e.stopPropagation();
+ void onPlay(asset.id);
+ }}
+ className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40"
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+
+
+ )}
+
+ {asset.name || t('unnamedAsset')}
+
+
+
+ {/* Tags UI - commented out */}
+ {/*
+
+ {tags.length === 0 ? (
+ !isPublished && (
+
+
+
+
+ )
+ ) : (
+
+
+
+
+ {tag && `${tag.key}${tag.value && `: ${tag.value}`}`}
+
+
+
+ )}
+
+ */}
+ {/* Show pencil button for local assets when not published, otherwise show download indicator */}
+ {!isPublished && onRename && asset.source === 'local' ? (
+ {
+ e.stopPropagation();
+ onRename(asset.id, asset.name);
+ }}
+ className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40"
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+
+
+ ) : (
+
+ )}
+
+ {SHOW_DEV_ELEMENTS && (
+
+ {`ID: ${asset.id.substring(0, 8)}...`}
+
+ )}
+
+
+ {/*
+
+
+ */}
+
+
+ {/* TagModal - commented out */}
+ {/* setIsTagModalVisible(false)}
+ onAssignTags={handleAssignTags}
+ /> */}
+
+ );
+};
+
+/**
+ * Custom comparison function for React.memo
+ * Returns TRUE if props are EQUAL (skip re-render)
+ * Returns FALSE if props are DIFFERENT (re-render needed)
+ */
+const arePropsEqual = (
+ prevProps: BibleAssetListItemProps,
+ nextProps: BibleAssetListItemProps
+): boolean => {
+ // 1. Compare primitive props that affect visual rendering
+ if (
+ prevProps.questId !== nextProps.questId ||
+ prevProps.isPublished !== nextProps.isPublished ||
+ prevProps.isCurrentlyPlaying !== nextProps.isCurrentlyPlaying ||
+ prevProps.showDragHandle !== nextProps.showDragHandle ||
+ prevProps.isDragFixed !== nextProps.isDragFixed ||
+ prevProps.isSelectionMode !== nextProps.isSelectionMode ||
+ prevProps.isSelected !== nextProps.isSelected ||
+ prevProps.isSelectedForRecording !== nextProps.isSelectedForRecording
+ ) {
+ return false; // Props changed, need to re-render
+ }
+
+ // 2. Compare asset object (only fields that affect UI)
+ const prevAsset = prevProps.asset;
+ const nextAsset = nextProps.asset;
+
+ if (
+ prevAsset.id !== nextAsset.id ||
+ prevAsset.name !== nextAsset.name ||
+ prevAsset.order_index !== nextAsset.order_index ||
+ prevAsset.visible !== nextAsset.visible ||
+ prevAsset.active !== nextAsset.active ||
+ prevAsset.quest_visible !== nextAsset.quest_visible ||
+ prevAsset.quest_active !== nextAsset.quest_active ||
+ prevAsset.source !== nextAsset.source
+ ) {
+ return false; // Asset changed, need to re-render
+ }
+
+ // 3. Compare metadata (small object, JSON.stringify is fast)
+ const prevMetadata = JSON.stringify(prevAsset.metadata);
+ const nextMetadata = JSON.stringify(nextAsset.metadata);
+ if (prevMetadata !== nextMetadata) {
+ return false; // Metadata changed, need to re-render
+ }
+
+ // 4. Compare attachmentState (only the state field matters for UI)
+ const prevState = prevProps.attachmentState?.state;
+ const nextState = nextProps.attachmentState?.state;
+ if (prevState !== nextState) {
+ return false; // Attachment state changed, need to re-render
+ }
+
+ // 5. Ignore function props (they're stable from Part 2 optimization)
+ // onUpdate, onPlay, onToggleSelect, onEnterSelection, onSelectForRecording, onRename
+ // These are ignored because they're memoized in the parent component
+
+ return true; // Props are equal, skip re-render β
+};
+
+/**
+ * Memoized BibleAssetListItem component
+ * Only re-renders when props that affect visual output change
+ */
+export const BibleAssetListItem = React.memo(
+ BibleAssetListItemComponent,
+ arePropsEqual
+);
+
+BibleAssetListItem.displayName = 'BibleAssetListItem';
diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx
new file mode 100644
index 000000000..4a46a0d49
--- /dev/null
+++ b/views/new/BibleAssetsView.tsx
@@ -0,0 +1,3741 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-condition */
+import { AssetsDeletionDrawer } from '@/components/AssetsDeletionDrawer';
+import { QuestSettingsModal } from '@/components/QuestSettingsModal';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { Input } from '@/components/ui/input';
+import {
+ SpeedDial,
+ SpeedDialItem,
+ SpeedDialItems,
+ SpeedDialTrigger
+} from '@/components/ui/speed-dial';
+import { Text } from '@/components/ui/text';
+import { useAudio } from '@/contexts/AudioContext';
+import { useAuth } from '@/contexts/AuthContext';
+import { LayerType, useStatusContext } from '@/contexts/StatusContext';
+import type { asset } from '@/db/drizzleSchema';
+import {
+ asset_content_link,
+ project,
+ quest as questTable
+} from '@/db/drizzleSchema';
+import { system } from '@/db/powersync/system';
+import { useDebouncedState } from '@/hooks/use-debounced-state';
+import {
+ useAppNavigation,
+ useCurrentNavigation
+} from '@/hooks/useAppNavigation';
+import { useAttachmentStates } from '@/hooks/useAttachmentStates';
+import { useLocalization } from '@/hooks/useLocalization';
+import { useQuestDownloadStatusLive } from '@/hooks/useQuestDownloadStatusLive';
+import { useUserPermissions } from '@/hooks/useUserPermissions';
+import { useLocalStore } from '@/store/localStore';
+import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
+import RNAlert from '@blazejkustra/react-native-alert';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { Audio } from 'expo-av';
+import {
+ BookmarkPlusIcon,
+ BrushCleaning,
+ CheckCheck,
+ ChevronRight,
+ CloudUpload,
+ FlagIcon,
+ InfoIcon,
+ LockIcon,
+ MicIcon,
+ PauseIcon,
+ PlayIcon,
+ RefreshCwIcon,
+ SearchIcon,
+ SettingsIcon,
+ UserPlusIcon
+} from 'lucide-react-native';
+import React from 'react';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import Animated, {
+ cancelAnimation,
+ Easing,
+ runOnJS,
+ useAnimatedRef,
+ useAnimatedScrollHandler,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useHybridData } from './useHybridData';
+
+import { AssetListSkeleton } from '@/components/AssetListSkeleton';
+import { ExportButton } from '@/components/ExportButton';
+import { ModalDetails } from '@/components/ModalDetails';
+import { ReportModal } from '@/components/NewReportModal';
+import { PrivateAccessGate } from '@/components/PrivateAccessGate';
+import { QuestOffloadVerificationDrawer } from '@/components/QuestOffloadVerificationDrawer';
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerScrollView,
+ DrawerTitle
+} from '@/components/ui/drawer';
+import { VerseAssigner } from '@/components/VerseAssigner';
+import { VerseRangeSelector } from '@/components/VerseRangeSelector';
+import { VerseSeparator } from '@/components/VerseSeparator';
+import { BIBLE_BOOKS } from '@/constants/bibleStructure';
+import type { AssetUpdatePayload } from '@/database_services/assetService';
+import {
+ batchUpdateAssetMetadata,
+ renameAsset
+} from '@/database_services/assetService';
+import { audioSegmentService } from '@/database_services/audioSegmentService';
+import { AppConfig } from '@/db/supabase/AppConfig';
+import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets';
+import { useBlockedAssetsCount } from '@/hooks/useBlockedCount';
+import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification';
+import { useHasUserReported } from '@/hooks/useReports';
+import { resolveTable } from '@/utils/dbUtils';
+import { fileExists, getLocalAttachmentUriWithOPFS } from '@/utils/fileUtils';
+import { publishQuest as publishQuestUtils } from '@/utils/publishUtils';
+import { offloadQuest } from '@/utils/questOffloadUtils';
+import { getThemeColor } from '@/utils/styleUtils';
+import { toCompilableQuery } from '@powersync/drizzle-driver';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { and, asc, eq, gte, lte } from 'drizzle-orm';
+import { ScrollView as GHScrollView } from 'react-native-gesture-handler';
+import Sortable from 'react-native-sortables';
+import { BibleAssetListItem } from './BibleAssetListItem';
+import BibleRecordingView from './recording/components/BibleRecordingView';
+import { BibleSelectionControls } from './recording/components/BibleSelectionControls';
+import { RenameAssetDrawer } from './recording/components/RenameAssetDrawer';
+import { useSelectionMode } from './recording/hooks/useSelectionMode';
+// import RecordingViewSimplified from './recording/components/RecordingViewSimplified';
+
+type Asset = typeof asset.$inferSelect;
+
+interface AssetMetadata {
+ verse?: {
+ from: number;
+ to: number;
+ };
+}
+
+type AssetQuestLink = Asset & {
+ quest_active: boolean;
+ quest_visible: boolean;
+ tag_ids?: string[] | undefined;
+ metadata?: AssetMetadata | null;
+};
+
+// List item types for rendering
+interface ListItemAsset {
+ type: 'asset';
+ content: AssetQuestLink;
+ key: string;
+}
+
+interface ListItemSeparator {
+ type: 'separator';
+ from?: number;
+ to?: number;
+ key: string;
+}
+
+type ListItem = ListItemAsset | ListItemSeparator;
+
+// Manual separator type used for verse grouping
+interface ManualSeparator {
+ from: number;
+ to: number;
+ key: string;
+ assetId?: string;
+}
+
+const RecordingPlaceIndicator = () => (
+
+ {/* */}
+
+ REC
+
+);
+
+// ============================================================================
+// HELPER FUNCTIONS (moved outside component for better performance)
+// ============================================================================
+
+/**
+ * Builds the final list of items (assets + separators) for rendering.
+ * This is extracted as a pure function to avoid recreation on each render.
+ */
+function buildFinalList(
+ assetsWithMeta: AssetQuestLink[],
+ assetsWithoutMeta: AssetQuestLink[],
+ separatorsWithAssetId: ManualSeparator[],
+ sortedSeparatorsWithoutAssetId: ManualSeparator[],
+ allManualSeparators: ManualSeparator[]
+): ListItem[] {
+ // Build list with auto-generated separators + assets with metadata
+ const result: ListItem[] = [];
+ let currentFrom: number | undefined;
+ let currentTo: number | undefined;
+
+ for (const asset of assetsWithMeta) {
+ const from = asset.metadata?.verse?.from;
+ const to = asset.metadata?.verse?.to;
+
+ // Add separator when verse range changes
+ if (from !== currentFrom || to !== currentTo) {
+ result.push({
+ type: 'separator',
+ from,
+ to,
+ key: `sep-${from}-${to}`
+ });
+ currentFrom = from;
+ currentTo = to;
+ }
+
+ result.push({
+ type: 'asset',
+ content: asset,
+ key: asset.id
+ });
+ }
+
+ // Build unassigned block (assets without verse metadata)
+ const unassignedBlock: ListItem[] = [];
+ if (assetsWithoutMeta.length > 0) {
+ unassignedBlock.push({
+ type: 'separator',
+ key: 'sep-unassigned'
+ });
+
+ for (const asset of assetsWithoutMeta) {
+ unassignedBlock.push({
+ type: 'asset',
+ content: asset,
+ key: asset.id
+ });
+ }
+ }
+
+ // Insert separators that target a specific asset
+ for (const sep of separatorsWithAssetId) {
+ if (!sep.assetId) continue;
+
+ const assetIndex = result.findIndex(
+ (item) => item.type === 'asset' && item.content.id === sep.assetId
+ );
+
+ const sepItem: ListItemSeparator = {
+ type: 'separator',
+ from: sep.from,
+ to: sep.to,
+ key: sep.key
+ };
+
+ if (assetIndex !== -1) {
+ result.splice(assetIndex, 0, sepItem);
+ } else {
+ // Asset is in unassignedBlock, insert at end of result
+ result.push(sepItem);
+ }
+ }
+
+ // Insert separators without assetId by verse order
+ for (const sep of sortedSeparatorsWithoutAssetId) {
+ const sepItem: ListItemSeparator = {
+ type: 'separator',
+ from: sep.from,
+ to: sep.to,
+ key: sep.key
+ };
+
+ let insertIdx = result.findIndex(
+ (item) =>
+ item.type === 'separator' &&
+ item.from !== undefined &&
+ sep.from < item.from
+ );
+ if (insertIdx === -1) {
+ insertIdx = result.length;
+ }
+ result.splice(insertIdx, 0, sepItem);
+ }
+
+ // Combine: result + unassigned block
+ const combined: ListItem[] = [...result, ...unassignedBlock];
+
+ // Build set of manual separator ranges for deduplication
+ const manualSeparatorRanges = new Set();
+ const manualSeparatorKeys = new Set();
+ for (const sep of allManualSeparators) {
+ manualSeparatorRanges.add(`${sep.from ?? 'none'}-${sep.to ?? 'none'}`);
+ manualSeparatorKeys.add(sep.key);
+ }
+
+ // Deduplicate separators (prefer manual over auto-generated)
+ const seenSeparatorRanges = new Set();
+ const deduped: ListItem[] = [];
+
+ for (const item of combined) {
+ if (item.type === 'separator') {
+ const sepRange = `${item.from ?? 'none'}-${item.to ?? 'none'}`;
+ const isManualSeparator = manualSeparatorKeys.has(item.key);
+ const hasManualSeparatorForRange = manualSeparatorRanges.has(sepRange);
+
+ // Skip if we've already seen this range
+ if (seenSeparatorRanges.has(sepRange)) {
+ continue;
+ }
+
+ // Skip auto-generated if manual exists for this range
+ if (!isManualSeparator && hasManualSeparatorForRange) {
+ continue;
+ }
+
+ seenSeparatorRanges.add(sepRange);
+ }
+ deduped.push(item);
+ }
+
+ return deduped;
+}
+
+export default function BibleAssetsView() {
+ const {
+ currentQuestId,
+ currentProjectId,
+ currentProjectData,
+ currentQuestData,
+ currentBookId
+ } = useCurrentNavigation();
+ const { goBack } = useAppNavigation();
+ const { currentUser } = useAuth();
+ const audioContext = useAudio();
+ const queryClient = useQueryClient();
+ const insets = useSafeAreaInsets();
+
+ // Selection mode for batch operations
+ const {
+ isSelectionMode,
+ selectedAssetIds,
+ enterSelection,
+ toggleSelect,
+ cancelSelection
+ } = useSelectionMode();
+ const [debouncedSearchQuery, searchQuery, setSearchQuery] = useDebouncedState(
+ '',
+ 300
+ );
+ const { t } = useLocalization();
+ const [showDetailsModal, setShowDetailsModal] = React.useState(false);
+ const [showSettingsModal, setShowSettingsModal] = React.useState(false);
+ const [showReportModal, setShowReportModal] = React.useState(false);
+ const [showOffloadDrawer, setShowOffloadDrawer] = React.useState(false);
+ const [showDeleteAllDrawer, setShowDeleteAllDrawer] = React.useState(false);
+ const [verseSelectorState, setVerseSelectorState] = React.useState<{
+ isOpen: boolean;
+ key: string | null;
+ from?: number;
+ to?: number;
+ }>({ isOpen: false, key: null });
+
+ // State for adding new label (not editing existing)
+ const [newLabelSelectorState, setNewLabelSelectorState] = React.useState<{
+ isOpen: boolean;
+ from?: number;
+ to?: number;
+ }>({ isOpen: false });
+
+ // State for adding verse label above a specific asset
+ const [assetVerseSelectorState, setAssetVerseSelectorState] = React.useState<{
+ isOpen: boolean;
+ assetId: string | null;
+ from?: number;
+ to?: number;
+ }>({ isOpen: false, assetId: null });
+
+ // State for editing an existing separator
+ const [editSeparatorState, setEditSeparatorState] = React.useState<{
+ isOpen: boolean;
+ separatorKey: string | null;
+ from?: number;
+ to?: number;
+ }>({ isOpen: false, separatorKey: null });
+
+ // State for renaming assets
+ const [showRenameDrawer, setShowRenameDrawer] = React.useState(false);
+ const [renameAssetId, setRenameAssetId] = React.useState(null);
+ const [renameAssetName, setRenameAssetName] = React.useState('');
+
+ // State for batch verse assignment
+ const [showVerseAssignerDrawer, setShowVerseAssignerDrawer] =
+ React.useState(false);
+
+ // Manual verse separators created by the user
+ const [manualSeparators, setManualSeparators] = React.useState<
+ { from: number; to: number; key: string; assetId?: string }[]
+ >([]);
+
+ // Track which separators have been processed for auto-assignment
+ const processedSeparatorsRef = React.useRef>(new Set());
+
+ // Function to add a new verse separator
+ // If assetId is provided, insert the separator right above that asset
+ const addVerseSeparator = React.useCallback(
+ (from: number, to: number, assetId?: string) => {
+ const newSeparator = {
+ from,
+ to,
+ key: `manual-sep-${from}-${to}-${Date.now()}`,
+ assetId // Store assetId to know where to insert it
+ };
+ setManualSeparators((prev) => [...prev, newSeparator]);
+ },
+ []
+ );
+
+ const [showPrivateAccessModal, setShowPrivateAccessModal] =
+ React.useState(false);
+ const [isOffloading, setIsOffloading] = React.useState(false);
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+ // Track which asset is currently playing during play-all
+ const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState<
+ string | null
+ >(null);
+ const assetUriMapRef = React.useRef