diff --git a/.gitignore b/.gitignore index 6330b72..c6f85b3 100644 --- a/.gitignore +++ b/.gitignore @@ -261,7 +261,11 @@ drizzle/ # Claude Code .claude + +# Environment files /.env +.env +apps/mobile/v1/.env # Windows reserved device names nul diff --git a/apps/mobile/v1/app/_layout.tsx b/apps/mobile/v1/app/_layout.tsx index 1aec501..4d2ca36 100644 --- a/apps/mobile/v1/app/_layout.tsx +++ b/apps/mobile/v1/app/_layout.tsx @@ -165,10 +165,6 @@ function AppContent() { } export default Sentry.wrap(function RootLayout() { - if (__DEV__) { - console.log('=== MOBILE V1 APP WITH CLERK ==='); - console.log('Clerk key loaded:', clerkPublishableKey ? 'YES' : 'NO'); - } // Lock orientation based on device type (phones: portrait only, tablets: all) useOrientationLock(); diff --git a/apps/mobile/v1/src/components/PublishNoteSheet.tsx b/apps/mobile/v1/src/components/PublishNoteSheet.tsx new file mode 100644 index 0000000..42b521f --- /dev/null +++ b/apps/mobile/v1/src/components/PublishNoteSheet.tsx @@ -0,0 +1,489 @@ +/** + * Publish Note Bottom Sheet + * Allows users to publish/unpublish notes as public web pages + */ + +import { Ionicons } from '@expo/vector-icons'; +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetTextInput, + BottomSheetView, +} from '@gorhom/bottom-sheet'; +import * as Haptics from 'expo-haptics'; +import { GlassView } from 'expo-glass-effect'; +import { Globe } from 'lucide-react-native'; +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, Share, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { type Note, useApiService } from '@/src/services/api'; +import { useTheme } from '@/src/theme'; + +interface PublishNoteSheetProps { + onPublishStateChange?: (noteId: string, isPublished: boolean, slug?: string) => void; +} + +export interface PublishNoteSheetRef { + present: (note: Note) => void; + dismiss: () => void; +} + +export const PublishNoteSheet = forwardRef( + ({ onPublishStateChange }, ref) => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const api = useApiService(); + const bottomSheetRef = useRef(null); + + const [currentNote, setCurrentNote] = useState(null); + const [authorName, setAuthorName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isPublished, setIsPublished] = useState(false); + const [publicSlug, setPublicSlug] = useState(null); + const [publishedAt, setPublishedAt] = useState(null); + const [lastSynced, setLastSynced] = useState(null); + + const snapPoints = useMemo(() => ['65%'], []); + + useImperativeHandle(ref, () => ({ + present: async (note: Note) => { + setCurrentNote(note); + setIsPublished(note.isPublished || false); + setPublicSlug(note.publicSlug || null); + setPublishedAt(note.publishedAt || null); + setLastSynced(note.publicUpdatedAt || note.publishedAt || null); + setAuthorName(''); + setIsLoading(false); + // Force close first then present to ensure clean state + bottomSheetRef.current?.dismiss(); + // Small delay to ensure dismiss completes before present + setTimeout(() => { + bottomSheetRef.current?.present(); + }, 50); + + // Fetch latest public note info if published + if (note.isPublished && note.publicSlug) { + try { + const info = await api.getPublicNoteInfo(note.id); + if (info) { + setAuthorName(info.authorName || ''); + setLastSynced(info.updatedAt); + } + } catch (error) { + // Ignore errors, use cached info + } + } + }, + dismiss: () => { + bottomSheetRef.current?.dismiss(); + }, + })); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + const handlePublish = async () => { + if (!currentNote) return; + + try { + setIsLoading(true); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + const result = await api.publishNote({ + noteId: currentNote.id, + title: currentNote.title, + content: currentNote.content, + type: currentNote.type, + authorName: authorName.trim() || undefined, + }); + + setIsPublished(true); + setPublicSlug(result.slug); + setPublishedAt(result.publishedAt); + setLastSynced(result.updatedAt); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onPublishStateChange?.(currentNote.id, true, result.slug); + } catch (error) { + console.error('Failed to publish note:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Error', 'Failed to publish note. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleUnpublish = async () => { + if (!currentNote || !publicSlug) return; + + Alert.alert( + 'Unpublish Note', + 'This will remove the public version of your note. Anyone with the link will no longer be able to view it.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Unpublish', + style: 'destructive', + onPress: async () => { + try { + setIsLoading(true); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + await api.unpublishNote(publicSlug); + + setIsPublished(false); + setPublicSlug(null); + setPublishedAt(null); + setLastSynced(null); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onPublishStateChange?.(currentNote.id, false); + bottomSheetRef.current?.dismiss(); + } catch (error) { + console.error('Failed to unpublish note:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Error', 'Failed to unpublish note. Please try again.'); + } finally { + setIsLoading(false); + } + }, + }, + ] + ); + }; + + const handleCopyLink = async () => { + if (!publicSlug || !currentNote) return; + + const url = api.getPublicUrl(publicSlug); + try { + // Use Share API - on iOS the share sheet includes a "Copy" option + await Share.share({ + title: currentNote.title, + message: url, + url: url, + }); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } catch (error) { + // User cancelled or error + console.log('Share cancelled or failed:', error); + } + }; + + const handleShare = async () => { + if (!publicSlug || !currentNote) return; + + const url = api.getPublicUrl(publicSlug); + try { + await Share.share({ + title: currentNote.title, + message: `${currentNote.title}\n\n${url}`, + url: url, + }); + } catch (error) { + console.error('Failed to share:', error); + } + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (!currentNote) return null; + + return ( + + + {/* Header */} + + + + + {isPublished ? 'Published Note' : 'Publish Note'} + + + bottomSheetRef.current?.dismiss()} + > + + + + + + + + + + {/* Content */} + + {isPublished ? ( + <> + {/* Published State */} + + + Public URL + + + {publicSlug ? api.getPublicUrl(publicSlug) : ''} + + + + {/* Action Buttons */} + + + + + Copy Link + + + + + + + Share + + + + + {/* Last Synced */} + {lastSynced && ( + + Last synced: {formatDate(lastSynced)} + + )} + + {/* Unpublish Button */} + + {isLoading ? ( + + ) : ( + + Unpublish Note + + )} + + + ) : ( + <> + {/* Warning */} + + + + Publishing bypasses end-to-end encryption. An unencrypted copy will be stored on our servers and anyone with the link can view it. + + + + {/* Author Name Input */} + + + Author Name (optional) + + + + + {/* Publish Button */} + + {isLoading ? ( + + ) : ( + <> + + + Publish Note + + + )} + + + )} + + + + ); + } +); + +PublishNoteSheet.displayName = 'PublishNoteSheet'; + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 0, + paddingBottom: 12, + }, + headerTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + }, + glassButton: { + borderRadius: 17, + overflow: 'hidden', + }, + iconButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + }, + divider: { + height: 0.5, + }, + content: { + paddingHorizontal: 20, + paddingTop: 20, + }, + warningBox: { + flexDirection: 'row', + padding: 14, + borderRadius: 10, + borderWidth: 1, + marginBottom: 20, + }, + warningText: { + flex: 1, + fontSize: 14, + lineHeight: 20, + }, + inputGroup: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 13, + fontWeight: '500', + marginBottom: 8, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 16, + }, + publishButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 10, + gap: 8, + }, + publishButtonText: { + fontSize: 16, + fontWeight: '600', + }, + urlContainer: { + padding: 14, + borderRadius: 10, + marginBottom: 16, + }, + urlLabel: { + fontSize: 12, + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 6, + }, + urlText: { + fontSize: 14, + fontWeight: '500', + }, + actionButtons: { + flexDirection: 'row', + gap: 12, + marginBottom: 16, + }, + actionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 10, + gap: 6, + }, + actionButtonText: { + fontSize: 14, + fontWeight: '600', + }, + syncedText: { + fontSize: 13, + textAlign: 'center', + marginBottom: 20, + }, + unpublishButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 10, + borderWidth: 1, + gap: 8, + }, + unpublishButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/apps/mobile/v1/src/hooks/useViewNote.ts b/apps/mobile/v1/src/hooks/useViewNote.ts index beb89bb..f934356 100644 --- a/apps/mobile/v1/src/hooks/useViewNote.ts +++ b/apps/mobile/v1/src/hooks/useViewNote.ts @@ -17,6 +17,7 @@ interface UseViewNoteReturn { handleToggleStar: () => Promise; handleToggleHidden: () => Promise; refresh: () => Promise; + updateNoteLocally: (updates: Partial) => void; } /** @@ -82,7 +83,14 @@ export function useViewNote(noteId: string): UseViewNoteReturn { attributes: { noteId: note.id, currentStarred: note.starred, newStarred: !note.starred } }); const updatedNote = await api.updateNote(note.id, { starred: !note.starred }); - setNote(updatedNote); + // Preserve public note fields that may not be returned by the API + setNote({ + ...updatedNote, + isPublished: note.isPublished, + publicSlug: note.publicSlug, + publishedAt: note.publishedAt, + publicUpdatedAt: note.publicUpdatedAt, + }); logger.info('[NOTE] Star toggled successfully', { attributes: { noteId: note.id, starred: updatedNote.starred } }); @@ -97,6 +105,16 @@ export function useViewNote(noteId: string): UseViewNoteReturn { const handleToggleHidden = async () => { if (!note) return; + // Prevent hiding public notes + if (note.isPublished && !note.hidden) { + Alert.alert( + 'Cannot Hide Public Note', + 'Please unpublish this note before hiding it.', + [{ text: 'OK' }] + ); + return; + } + try { await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); logger.info('[NOTE] Toggling hidden', { @@ -105,7 +123,14 @@ export function useViewNote(noteId: string): UseViewNoteReturn { const updatedNote = note.hidden ? await api.unhideNote(note.id) : await api.hideNote(note.id); - setNote(updatedNote); + // Preserve public note fields that may not be returned by the API + setNote({ + ...updatedNote, + isPublished: note.isPublished, + publicSlug: note.publicSlug, + publishedAt: note.publishedAt, + publicUpdatedAt: note.publicUpdatedAt, + }); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); logger.info('[NOTE] Hidden toggled successfully', { attributes: { noteId: note.id, hidden: updatedNote.hidden } @@ -124,6 +149,12 @@ export function useViewNote(noteId: string): UseViewNoteReturn { [note, theme.colors, theme.isDark] ); + const updateNoteLocally = (updates: Partial) => { + if (note) { + setNote({ ...note, ...updates }); + } + }; + return { note, loading, @@ -132,5 +163,6 @@ export function useViewNote(noteId: string): UseViewNoteReturn { handleToggleStar, handleToggleHidden, refresh, + updateNoteLocally, }; } diff --git a/apps/mobile/v1/src/lib/database/index.ts b/apps/mobile/v1/src/lib/database/index.ts index c19e81b..75950f4 100644 --- a/apps/mobile/v1/src/lib/database/index.ts +++ b/apps/mobile/v1/src/lib/database/index.ts @@ -13,7 +13,7 @@ import * as SQLite from 'expo-sqlite'; let database: SQLite.SQLiteDatabase | null = null; const DB_NAME = 'typelets_mobile.db'; -const DB_VERSION = 4; +const DB_VERSION = 5; /** * Migrate database schema to latest version @@ -117,6 +117,32 @@ async function migrateDatabase(db: SQLite.SQLiteDatabase): Promise { } } + if (currentVersion < 5) { + // Migration to v5: Add public notes columns + console.log('[SQLite] Running migration to v5...'); + + try { + await db.execAsync(` + -- Add public notes columns to notes table + ALTER TABLE notes ADD COLUMN is_published INTEGER DEFAULT 0; + ALTER TABLE notes ADD COLUMN public_slug TEXT; + ALTER TABLE notes ADD COLUMN published_at TEXT; + ALTER TABLE notes ADD COLUMN public_updated_at TEXT; + `); + + // Clear notes cache so they get re-fetched with public notes fields + console.log('[SQLite] Clearing notes cache to refresh with public notes fields...'); + await db.execAsync(`DELETE FROM notes;`); + await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`); + console.log('[SQLite] Notes cache cleared - will be refreshed on next load'); + + console.log('[SQLite] Migration to v5 completed'); + } catch (error) { + console.error('[SQLite] Migration to v5 failed:', error); + throw error; + } + } + // Update schema version await db.execAsync(`PRAGMA user_version = ${DB_VERSION}`); console.log(`[SQLite] Database migrated to v${DB_VERSION}`); @@ -144,6 +170,32 @@ async function migrateDatabase(db: SQLite.SQLiteDatabase): Promise { } catch (error) { console.error('[SQLite] Failed to check/add attachment_count column:', error); } + + // Safety check: Ensure public notes columns exist (runs every time) + try { + const columnCheck = await db.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM pragma_table_info('notes') WHERE name='is_published'` + ); + + if (columnCheck && columnCheck.count === 0) { + console.log('[SQLite] Public notes columns missing, adding them now...'); + await db.execAsync(` + ALTER TABLE notes ADD COLUMN is_published INTEGER DEFAULT 0; + ALTER TABLE notes ADD COLUMN public_slug TEXT; + ALTER TABLE notes ADD COLUMN published_at TEXT; + ALTER TABLE notes ADD COLUMN public_updated_at TEXT; + `); + console.log('[SQLite] Public notes columns added successfully'); + + // Clear notes cache so they get re-fetched with public notes fields + console.log('[SQLite] Clearing notes cache to refresh with public notes fields...'); + await db.execAsync(`DELETE FROM notes;`); + await db.execAsync(`DELETE FROM cache_metadata WHERE resource_type = 'notes';`); + console.log('[SQLite] Notes cache cleared - will be refreshed on next load'); + } + } catch (error) { + console.error('[SQLite] Failed to check/add public notes columns:', error); + } } /** @@ -190,7 +242,11 @@ export async function initializeDatabase(): Promise { encrypted_content TEXT, iv TEXT, salt TEXT, - attachment_count INTEGER DEFAULT 0 + attachment_count INTEGER DEFAULT 0, + is_published INTEGER DEFAULT 0, + public_slug TEXT, + published_at TEXT, + public_updated_at TEXT ); -- Folders table with cache fields diff --git a/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts b/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts index 17c7613..6fc6204 100644 --- a/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts +++ b/apps/mobile/v1/src/lib/encryption/masterPassword/setup.ts @@ -13,10 +13,6 @@ export async function setupMasterPassword( masterPassword: string, userId: string ): Promise { - if (__DEV__) { - console.log('๐Ÿ”’ setupMasterPassword called for user:', userId); - } - if (!userId) { throw new Error('Master password setup attempted without user ID'); } @@ -24,34 +20,15 @@ export async function setupMasterPassword( try { const userSalt = getUserSalt(userId); - if (__DEV__) { - console.log('๐Ÿ”’ Starting PBKDF2 key derivation...'); - } - // Use PBKDF2 implementation (will block for ~2 minutes) const keyString = await pbkdf2(masterPassword, userSalt); - if (__DEV__) { - console.log('๐Ÿ”’ PBKDF2 completed, storing keys...'); - } - // Store master key await storeMasterKey(userId, keyString); - if (__DEV__) { - console.log('๐Ÿ”’ Master key stored'); - } - // Remove old key if exists await deleteOldUserSecret(userId); - - if (__DEV__) { - console.log('๐Ÿ”’ setupMasterPassword completed successfully'); - } } catch (error) { - if (__DEV__) { - console.log('๐Ÿ”’ setupMasterPassword error:', error); - } throw new Error(`Master password setup failed: ${error}`); } } diff --git a/apps/mobile/v1/src/lib/encryption/storage/cache.ts b/apps/mobile/v1/src/lib/encryption/storage/cache.ts index 50fad3f..5eb921f 100644 --- a/apps/mobile/v1/src/lib/encryption/storage/cache.ts +++ b/apps/mobile/v1/src/lib/encryption/storage/cache.ts @@ -91,10 +91,6 @@ export class DecryptionCache { }); keysToDelete.forEach((key) => this.cache.delete(key)); - - if (__DEV__ && keysToDelete.length > 0) { - console.log(`๐Ÿงน Cleared ${keysToDelete.length} cache entries for user ${userId}`); - } } /** @@ -118,10 +114,6 @@ export class DecryptionCache { }); expiredKeys.forEach((key) => this.cache.delete(key)); - - if (__DEV__ && expiredKeys.length > 0) { - console.log(`๐Ÿงน Cleaned ${expiredKeys.length} expired cache entries`); - } } /** diff --git a/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts b/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts index abccd89..2310672 100644 --- a/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts +++ b/apps/mobile/v1/src/lib/encryption/storage/secureStorage.ts @@ -104,13 +104,8 @@ export async function clearUserStorageData(userId: string): Promise { for (const key of keysToDelete) { try { await SecureStore.deleteItemAsync(key); - if (__DEV__) { - console.log(`โœ… Deleted ${key}`); - } } catch (error) { - if (__DEV__) { - console.log(`โŒ Failed to delete ${key}:`, error); - } + // Silently ignore deletion errors } } diff --git a/apps/mobile/v1/src/screens/EditNote/index.tsx b/apps/mobile/v1/src/screens/EditNote/index.tsx index e688533..be14470 100644 --- a/apps/mobile/v1/src/screens/EditNote/index.tsx +++ b/apps/mobile/v1/src/screens/EditNote/index.tsx @@ -161,6 +161,27 @@ export default function EditNoteScreen() { attributes: { noteId: currentNoteId, title: titleToUse } }); + // Auto-sync public note if published + if (noteData?.isPublished && noteData?.publicSlug) { + try { + logger.info('[NOTE] Auto-syncing public note', { + attributes: { noteId: currentNoteId, slug: noteData.publicSlug } + }); + await api.updatePublicNote(noteData.publicSlug, { + title: titleToUse, + content: htmlContent, + }); + logger.info('[NOTE] Public note synced successfully', { + attributes: { noteId: currentNoteId, slug: noteData.publicSlug } + }); + } catch (syncError) { + // Log error but don't fail the save - the private note was saved successfully + logger.error('[NOTE] Failed to sync public note', syncError instanceof Error ? syncError : undefined, { + attributes: { noteId: currentNoteId, slug: noteData.publicSlug } + }); + } + } + // Emit event for optimistic UI update in notes list DeviceEventEmitter.emit('noteUpdated', savedNote); } else { diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/CreateFolderSheet.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/CreateFolderSheet.tsx index d885164..f7755fd 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/CreateFolderSheet.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/CreateFolderSheet.tsx @@ -94,14 +94,13 @@ export const CreateFolderSheet = forwardRef Create Folder - - (ref as React.RefObject).current?.dismiss()} - > - - - + (ref as React.RefObject).current?.dismiss()}> + + + + + + diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/FilterSortSheet.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/FilterSortSheet.tsx index 220c4a0..43df9e8 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/FilterSortSheet.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/FilterSortSheet.tsx @@ -12,6 +12,7 @@ export interface FilterConfig { showHiddenOnly: boolean; showCodeOnly: boolean; showDiagramOnly: boolean; + showPublicOnly: boolean; } export interface SortConfig { @@ -59,14 +60,13 @@ export const FilterSortSheet = forwardRef Filter & Sort - - (ref as React.RefObject).current?.dismiss()} - > - - - + (ref as React.RefObject).current?.dismiss()}> + + + + + + @@ -75,7 +75,7 @@ export const FilterSortSheet = forwardRefFILTER setFilterConfig({ showAttachmentsOnly: false, showStarredOnly: false, showHiddenOnly: false, showCodeOnly: false, showDiagramOnly: false })} + onPress={() => setFilterConfig({ showAttachmentsOnly: false, showStarredOnly: false, showHiddenOnly: false, showCodeOnly: false, showDiagramOnly: false, showPublicOnly: false })} activeOpacity={0.7} disabled={!hasActiveFilters} > @@ -143,6 +143,18 @@ export const FilterSortSheet = forwardRefDiagram + setFilterConfig(prev => ({ ...prev, showPublicOnly: !prev.showPublicOnly }))} + > + + Public + + SORT BY { setCurrentNote(noteToShow); setShowFolderPicker(false); - bottomSheetRef.current?.present(); + // Force close first then present to ensure clean state + bottomSheetRef.current?.dismiss(); + // Small delay to ensure dismiss completes before present + setTimeout(() => { + bottomSheetRef.current?.present(); + }, 50); }, dismiss: () => { bottomSheetRef.current?.dismiss(); @@ -145,20 +150,21 @@ export const NoteActionsSheet = forwardRef {showFolderPicker ? 'Move to Folder' : 'Note Actions'} - - { - if (showFolderPicker) { - setShowFolderPicker(false); - } else { - bottomSheetRef.current?.dismiss(); - } - }} - > - - - + { + if (showFolderPicker) { + setShowFolderPicker(false); + } else { + bottomSheetRef.current?.dismiss(); + } + }} + > + + + + + + diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx index bdf9afb..4014b91 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { GlassView } from 'expo-glass-effect'; -import { Code2, Network } from 'lucide-react-native'; +import { Code2, Globe, Network } from 'lucide-react-native'; import React, { useMemo, useRef } from 'react'; import { ActivityIndicator, Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; @@ -253,6 +253,9 @@ const NoteListItemComponent: React.FC = ({ {isCode && ( )} + {note.isPublished && ( + + )} {((note.attachments?.length ?? 0) > 0 || (note.attachmentCount ?? 0) > 0) && ( diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx index 87d821b..47d028f 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx @@ -74,6 +74,7 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa showHiddenOnly: false, showCodeOnly: false, showDiagramOnly: false, + showPublicOnly: false, }); const [sortConfig, setSortConfig] = useState({ option: 'created', @@ -420,7 +421,7 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa }, [allFolders]); // Check if any filters are active - const hasActiveFilters = filterConfig.showAttachmentsOnly || filterConfig.showStarredOnly || filterConfig.showHiddenOnly || filterConfig.showCodeOnly || filterConfig.showDiagramOnly; + const hasActiveFilters = filterConfig.showAttachmentsOnly || filterConfig.showStarredOnly || filterConfig.showHiddenOnly || filterConfig.showCodeOnly || filterConfig.showDiagramOnly || filterConfig.showPublicOnly; // Extract theme colors as individual values to prevent object recreation const foregroundColor = theme.colors.foreground; diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesFiltering.ts b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesFiltering.ts index bd8659c..dc533a0 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesFiltering.ts +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesFiltering.ts @@ -40,6 +40,10 @@ export function useNotesFiltering( const isDiagram = note.type === 'diagram' || (note.content && isDiagramContent(note.content)); if (!isDiagram) return false; } + // Check for public filter + if (filterConfig.showPublicOnly && !note.isPublished) { + return false; + } return true; }); diff --git a/apps/mobile/v1/src/screens/FolderNotes/index.tsx b/apps/mobile/v1/src/screens/FolderNotes/index.tsx index e5ae5b9..4658802 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/index.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/index.tsx @@ -332,79 +332,76 @@ export default function FolderNotesScreen({ folderId, folderName, viewType }: Fo /> - - router.back()} - > - - - + router.back()}> + + + + + + - + {title} - - breadcrumbSheetRef.current?.present()} - > - - - + breadcrumbSheetRef.current?.present()}> + + + + + + - - { - if (isSearchVisible) { - setIsSearchVisible(false); - setSearchQuery(''); - } else { - setIsSearchVisible(true); - } - }} - > - - - + { + if (isSearchVisible) { + setIsSearchVisible(false); + setSearchQuery(''); + } else { + setIsSearchVisible(true); + } + }} + > + + + + + + {/* Show settings button only for regular folders (not Quick Action views) */} {!viewType && folderId && ( - - - - - + + + + + + + )} {/* Avatar - navigates to settings */} - - router.push('/settings')} - hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} - > - + router.push('/settings')} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + > + + - - + + diff --git a/apps/mobile/v1/src/screens/Home/index.tsx b/apps/mobile/v1/src/screens/Home/index.tsx index 7548c7c..46e58fa 100644 --- a/apps/mobile/v1/src/screens/Home/index.tsx +++ b/apps/mobile/v1/src/screens/Home/index.tsx @@ -338,17 +338,16 @@ export default function HomeScreen() { {/* Quick Actions */} - - router.push('/edit-note')} - > - - + router.push('/edit-note')}> + + + + + + Start writing - Start writing - - + + {/* Special Views Section */} @@ -358,31 +357,33 @@ export default function HomeScreen() { {SPECIAL_VIEWS.map((view) => ( - - { - router.push({ - pathname: '/folder-notes', - params: { viewType: view.id } - }); - }} - > - - - + { + router.push({ + pathname: '/folder-notes', + params: { viewType: view.id } + }); + }} + > + + + + + + + + {view.label} + + + + + {counts[view.id as keyof typeof counts] || 0} + - - {view.label} - - - - - {counts[view.id as keyof typeof counts] || 0} - - - + + ))} @@ -394,98 +395,100 @@ export default function HomeScreen() { FOLDERS - - { + setViewMode('list'); + try { + await AsyncStorage.setItem('viewMode', 'list'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + + { - setViewMode('list'); - try { - await AsyncStorage.setItem('viewMode', 'list'); - } catch (error) { - if (__DEV__) console.error('Failed to save view mode:', error); - } - }} - > - - - - - + + + + + { + setViewMode('grid'); + try { + await AsyncStorage.setItem('viewMode', 'grid'); + } catch (error) { + if (__DEV__) console.error('Failed to save view mode:', error); + } + }} + > + + { - setViewMode('grid'); - try { - await AsyncStorage.setItem('viewMode', 'grid'); - } catch (error) { - if (__DEV__) console.error('Failed to save view mode:', error); - } - }} - > - - - + ]}> + + + + {/* Create Folder Button */} {viewMode === 'grid' ? ( - - createFolderSheetRef.current?.present()} + createFolderSheetRef.current?.present()}> + - - - - Create Folder - + + + + + Create Folder + + - - + + ) : ( - - createFolderSheetRef.current?.present()} + createFolderSheetRef.current?.present()}> + - - - - Create Folder - + + + + + Create Folder + + - - + + )} {allFolders.map((folder) => ( @@ -513,29 +516,31 @@ export default function HomeScreen() { ) : ( - - { - router.push({ - pathname: '/folder-notes', - params: { folderId: folder.id, folderName: folder.name } - }); - }} - > - - - - {folder.name} - - - - - {folder.noteCount || 0} - + { + router.push({ + pathname: '/folder-notes', + params: { folderId: folder.id, folderName: folder.name } + }); + }} + > + + + + + + {folder.name} + + + + + {folder.noteCount || 0} + + - - + + ) ))} diff --git a/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx index 6bed592..a0c3f78 100644 --- a/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx @@ -1,6 +1,7 @@ import { Ionicons } from '@expo/vector-icons'; import { GlassView } from 'expo-glass-effect'; import { LinearGradient } from 'expo-linear-gradient'; +import { Globe } from 'lucide-react-native'; import React from 'react'; import { Animated, StyleSheet, Text,TouchableOpacity, View } from 'react-native'; @@ -9,6 +10,7 @@ import { GLASS_BUTTON } from '@/src/constants/ui'; interface ViewHeaderProps { isStarred: boolean; isHidden: boolean; + isPublished?: boolean; title: string; scrollY: Animated.Value; attachmentsCount: number; @@ -21,6 +23,7 @@ interface ViewHeaderProps { onToggleHidden: () => void; onToggleAttachments: () => void; onEdit: () => void; + onPublish: () => void; theme: { colors: { primary: string; @@ -38,6 +41,7 @@ interface ViewHeaderProps { export function ViewHeader({ isStarred, isHidden, + isPublished = false, title, scrollY, attachmentsCount, @@ -50,6 +54,7 @@ export function ViewHeader({ onToggleHidden, onToggleAttachments, onEdit, + onPublish, theme, }: ViewHeaderProps) { const titleOpacity = scrollY.interpolate({ @@ -79,14 +84,13 @@ export function ViewHeader({ style={styles.gradient} /> - - - - - + + + + + + + @@ -104,66 +108,82 @@ export function ViewHeader({ {attachmentsCount > 0 && ( - - - - - - - {attachmentsCount > 0 && ( - - - {attachmentsCount > 9 ? '9+' : attachmentsCount} - + + + + + + - )} + {attachmentsCount > 0 && ( + + + {attachmentsCount > 9 ? '9+' : attachmentsCount} + + + )} + - - + + )} - - - - - + + + + + + + - - - - - + + + + + + + - - - - - + + + + + + + + + + + + + + + diff --git a/apps/mobile/v1/src/screens/ViewNote/index.tsx b/apps/mobile/v1/src/screens/ViewNote/index.tsx index 19a42a5..9cac849 100644 --- a/apps/mobile/v1/src/screens/ViewNote/index.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/index.tsx @@ -1,9 +1,11 @@ import { Ionicons } from '@expo/vector-icons'; +import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useFocusEffect,useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback,useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Animated, RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PublishNoteSheet, PublishNoteSheetRef } from '../../components/PublishNoteSheet'; import { useNetworkStatus } from '../../hooks/useNetworkStatus'; import { useViewNote } from '../../hooks/useViewNote'; import { logger } from '../../lib/logger'; @@ -33,8 +35,9 @@ export default function ViewNoteScreen() { const [attachments, setAttachments] = useState([]); const [loadingAttachments, setLoadingAttachments] = useState(false); const [downloadingId, setDownloadingId] = useState(null); + const publishSheetRef = useRef(null); - const { note, loading, htmlContent, handleEdit: handleEditInternal, handleToggleStar, handleToggleHidden, refresh } = useViewNote(noteId as string); + const { note, loading, htmlContent, handleEdit: handleEditInternal, handleToggleStar, handleToggleHidden, refresh, updateNoteLocally } = useViewNote(noteId as string); const [refreshing, setRefreshing] = useState(false); // Wrap handleEdit with offline check (allow editing temp notes created offline) @@ -274,8 +277,9 @@ export default function ViewNoteScreen() { {/* Floating Header */} setShowAttachments(!showAttachments)} onEdit={handleEdit} + onPublish={() => { + publishSheetRef.current?.present(note); + }} theme={theme} /> + {/* Publish Note Bottom Sheet */} + { + updateNoteLocally({ + isPublished, + publicSlug: slug || null, + publishedAt: isPublished ? new Date().toISOString() : null, + publicUpdatedAt: isPublished ? new Date().toISOString() : null, + }); + }} + /> + {/* Refresh indicator - rendered after header so it appears on top */} {refreshing && ( diff --git a/apps/mobile/v1/src/services/api/databaseCache.ts b/apps/mobile/v1/src/services/api/databaseCache.ts index bd95a12..94aae93 100644 --- a/apps/mobile/v1/src/services/api/databaseCache.ts +++ b/apps/mobile/v1/src/services/api/databaseCache.ts @@ -219,6 +219,10 @@ export async function getCachedNotes(filters?: { iv: row.iv || undefined, salt: row.salt || undefined, attachmentCount: row.attachment_count || 0, + isPublished: Boolean(row.is_published), + publicSlug: row.public_slug || null, + publishedAt: row.published_at || null, + publicUpdatedAt: row.public_updated_at || null, }; }); } catch (error) { @@ -284,8 +288,9 @@ export async function storeCachedNotes( `INSERT OR REPLACE INTO notes ( id, title, content, folder_id, user_id, starred, archived, deleted, hidden, created_at, updated_at, encrypted_title, encrypted_content, iv, salt, - is_synced, is_dirty, synced_at, attachment_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + is_synced, is_dirty, synced_at, attachment_count, + is_published, public_slug, published_at, public_updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ note.id, title, @@ -306,6 +311,10 @@ export async function storeCachedNotes( 0, // is_dirty now, // synced_at note.attachmentCount || 0, // attachment_count + note.isPublished ? 1 : 0, // is_published + note.publicSlug || null, // public_slug + note.publishedAt || null, // published_at + note.publicUpdatedAt || null, // public_updated_at ] ); } catch (noteError) { diff --git a/apps/mobile/v1/src/services/api/index.ts b/apps/mobile/v1/src/services/api/index.ts index 73ca1b8..7d5886a 100644 --- a/apps/mobile/v1/src/services/api/index.ts +++ b/apps/mobile/v1/src/services/api/index.ts @@ -7,11 +7,13 @@ import { useAuth, useUser } from '@clerk/clerk-expo'; import { createFoldersApi } from './folders'; import { createNotesApi } from './notes'; +import { createPublicNotesApi } from './publicNotes'; import { createUserApi } from './user'; // Re-export types for convenience export type { PickedFile } from '../fileService'; export type { + ApiPublicNote, ApiUser, ApiUserUsage, EmptyTrashResponse, @@ -36,6 +38,7 @@ export const useApiService = () => { const notesApi = createNotesApi(getToken, getUserId); const foldersApi = createFoldersApi(getToken); const userApi = createUserApi(getToken); + const publicNotesApi = createPublicNotesApi(getToken); // Return combined API surface return { @@ -69,5 +72,12 @@ export const useApiService = () => { // User methods getCurrentUser: userApi.getCurrentUser, + + // Public notes methods + publishNote: publicNotesApi.publishNote, + updatePublicNote: publicNotesApi.updatePublicNote, + unpublishNote: publicNotesApi.unpublishNote, + getPublicNoteInfo: publicNotesApi.getPublicNoteInfo, + getPublicUrl: publicNotesApi.getPublicUrl, }; }; diff --git a/apps/mobile/v1/src/services/api/notes/core.ts b/apps/mobile/v1/src/services/api/notes/core.ts index 0bd376e..2e8889f 100644 --- a/apps/mobile/v1/src/services/api/notes/core.ts +++ b/apps/mobile/v1/src/services/api/notes/core.ts @@ -609,6 +609,10 @@ export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => strin encryptedContent: row.encrypted_content || undefined, iv: row.iv || undefined, salt: row.salt || undefined, + isPublished: Boolean(row.is_published), + publicSlug: row.public_slug || null, + publishedAt: row.published_at || null, + publicUpdatedAt: row.public_updated_at || null, }; // Decrypt if needed (note already has decrypted content from database) diff --git a/apps/mobile/v1/src/services/api/publicNotes.ts b/apps/mobile/v1/src/services/api/publicNotes.ts new file mode 100644 index 0000000..57cbd50 --- /dev/null +++ b/apps/mobile/v1/src/services/api/publicNotes.ts @@ -0,0 +1,90 @@ +/** + * Public Notes API + * Handles publishing and unpublishing notes as public web pages + */ + +import { AuthTokenGetter, createHttpClient } from './client'; +import { ApiPublicNote, Note } from './types'; + +export interface PublishNoteParams { + noteId: string; + title: string; + content: string; + type?: 'note' | 'diagram' | 'code'; + authorName?: string; +} + +export interface PublishNoteResponse { + slug: string; + title: string; + content: string; + type?: 'note' | 'diagram' | 'code'; + authorName?: string; + publishedAt: string; + updatedAt: string; +} + +export interface UpdatePublicNoteParams { + title?: string; + content?: string; + authorName?: string; +} + +/** + * Creates the public notes API + */ +export function createPublicNotesApi(getToken: AuthTokenGetter) { + const { makeRequest } = createHttpClient(getToken); + + return { + /** + * Publish a note as a public web page + * Warning: This bypasses E2E encryption - an unencrypted copy is stored on the server + */ + async publishNote(params: PublishNoteParams): Promise { + return makeRequest('/public-notes', { + method: 'POST', + body: JSON.stringify(params), + }); + }, + + /** + * Update a published note + */ + async updatePublicNote(slug: string, params: UpdatePublicNoteParams): Promise { + return makeRequest(`/public-notes/${slug}`, { + method: 'PUT', + body: JSON.stringify(params), + }); + }, + + /** + * Unpublish a note (removes the public copy) + */ + async unpublishNote(slug: string): Promise<{ message: string }> { + return makeRequest<{ message: string }>(`/public-notes/${slug}`, { + method: 'DELETE', + }); + }, + + /** + * Get public note info for a note (authenticated - for owner) + */ + async getPublicNoteInfo(noteId: string): Promise { + try { + return await makeRequest(`/public-notes/note/${noteId}`); + } catch (error) { + // Return null if note is not published (404) + return null; + } + }, + + /** + * Get the public URL for a published note + */ + getPublicUrl(slug: string): string { + // The public URL is on the web app domain + return `https://app.typelets.com/p/${slug}`; + }, + }; +} diff --git a/apps/mobile/v1/src/services/api/types.ts b/apps/mobile/v1/src/services/api/types.ts index d66e44d..3566330 100644 --- a/apps/mobile/v1/src/services/api/types.ts +++ b/apps/mobile/v1/src/services/api/types.ts @@ -48,6 +48,25 @@ export interface Note { encryptedContent?: string; iv?: string; salt?: string; + // Public notes fields + isPublished?: boolean; + publicSlug?: string | null; + publishedAt?: string | null; + publicUpdatedAt?: string | null; +} + +// Public note API response +export interface ApiPublicNote { + id: string; + slug: string; + noteId: string; + userId: string; + title: string; + content: string; + type?: 'note' | 'diagram' | 'code'; + authorName?: string; + publishedAt: string; + updatedAt: string; } export interface PaginatedResponse { diff --git a/apps/mobile/v1/src/services/fileService.ts b/apps/mobile/v1/src/services/fileService.ts index d5a91ab..027519e 100644 --- a/apps/mobile/v1/src/services/fileService.ts +++ b/apps/mobile/v1/src/services/fileService.ts @@ -137,21 +137,14 @@ class FileService { userId: string ): Promise { try { - console.log('[Decrypt] Deriving key...'); - // Derive the base64 key (mobile-compatible) const keyBase64 = await this.deriveKeyForFiles(userId, saltBase64); - console.log('[Decrypt] Decrypting with AES-GCM...'); - // Decrypt the file content using mobile encryption helpers const decryptedBase64 = await decryptWithAESGCM(encryptedData, keyBase64, ivBase64); - console.log('[Decrypt] Decryption successful. Result length:', decryptedBase64.length); - return decryptedBase64; } catch (error) { - console.error('[Decrypt] Error:', error); throw new Error(`File decryption failed: ${error}`); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e29bbba..fd9ab81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13618,7 +13618,7 @@ snapshots: eslint: 9.36.0(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.36.0(jiti@2.6.1)) globals: 16.4.0 @@ -13658,7 +13658,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -13677,17 +13677,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.36.0(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) - eslint: 9.36.0(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -13708,35 +13697,6 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.36.0(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.36.0(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0