diff --git a/ROADMAP.md b/ROADMAP.md index 8ab4142..060b945 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,22 +21,22 @@ --- -## 🔥 Phase 1: Essential UX Foundations -*Timeline: 1-2 days | Priority: CRITICAL* +## ✅ Phase 1: Essential UX Foundations +*Timeline: 1-2 days | Priority: CRITICAL | **COMPLETED*** These features make the current experience sticky and shareable. | Feature | Status | Priority | Effort | Value | Notes | |---------|--------|----------|--------|-------|-------| -| **URL-Based Coordinate Sharing** | 📋 | 🔥 | ⚡ | ⭐⭐⭐⭐⭐ | Encode coords in URL for instant sharing | -| **Temporal Journal (Visit History)** | 📋 | 🔥 | ⚡⚡ | ⭐⭐⭐⭐⭐ | localStorage history of visited coords | -| **Image Gallery & Export** | 📋 | 🔥 | ⚡⚡ | ⭐⭐⭐⭐ | Save images to IndexedDB, download PNG | +| **URL-Based Coordinate Sharing** | ✅ | 🔥 | ⚡ | ⭐⭐⭐⭐⭐ | Encode coords in URL for instant sharing | +| **Temporal Journal (Visit History)** | ✅ | 🔥 | ⚡⚡ | ⭐⭐⭐⭐⭐ | localStorage history of visited coords | +| **Image Gallery & Export** | ✅ | 🔥 | ⚡⚡ | ⭐⭐⭐⭐ | Save images to IndexedDB, download PNG | **Deliverables**: -- Share button with copy-to-clipboard -- Journal panel in left sidebar -- Gallery modal with grid view -- Download as PNG feature +- ✅ Share button with copy-to-clipboard +- ✅ Journal panel in left sidebar +- ✅ Gallery modal with grid view +- ✅ Download as PNG feature --- @@ -111,31 +111,31 @@ Features that don't align with the app's vision: ## Current Sprint -**Active Sprint**: Phase 1 - Essential UX Foundations -**Start Date**: TBD -**Target Completion**: 1-2 days +**Active Sprint**: Phase 2 - Temporal Navigation +**Status**: Ready to start +**Previous Sprint**: Phase 1 - Essential UX Foundations ✅ COMPLETED -### Sprint Backlog +### Phase 1 Sprint Summary (Completed) -- [ ] 1.1 URL-Based Coordinate Sharing (2 hours) - - [ ] Create urlManager utility - - [ ] Integrate with ChronoscopeContext - - [ ] Add Share button to header - - [ ] Test with all coordinate types +- [x] 1.1 URL-Based Coordinate Sharing + - [x] Create urlManager utility + - [x] Integrate with ChronoscopeContext + - [x] Add Share button to header + - [x] Auto-update URL on scene render -- [ ] 1.2 Temporal Journal (4 hours) - - [ ] Create temporalJournal utility - - [ ] Build Journal component - - [ ] Add to left sidebar - - [ ] Implement export/import - - [ ] Test localStorage limits +- [x] 1.2 Temporal Journal + - [x] Create temporalJournal utility + - [x] Build Journal component + - [x] Add to left sidebar + - [x] Implement export/import + - [x] Auto-save on scene render -- [ ] 1.3 Image Gallery (6 hours) - - [ ] Set up IndexedDB schema - - [ ] Create imageGallery utility - - [ ] Build Gallery modal component - - [ ] Add download functionality - - [ ] Test storage limits +- [x] 1.3 Image Gallery + - [x] Set up IndexedDB schema with idb + - [x] Create galleryService utility + - [x] Build Gallery modal component + - [x] Add download functionality + - [x] Auto-save generated images --- @@ -196,7 +196,7 @@ Track these KPIs to guide future development: | Version | Date | Changes | |---------|------|---------| | 1.0.0 | 2025-11-28 | Initial app launch with core features | -| 1.1.0 | TBD | Phase 1: Essential UX (sharing, history, gallery) | +| 1.1.0 | 2025-11-28 | Phase 1: Essential UX (sharing, history, gallery) ✅ | | 1.2.0 | TBD | Phase 2: Temporal navigation (slider, chat) | | 2.0.0 | TBD | Phase 3: Enhanced discovery | diff --git a/package-lock.json b/package-lock.json index b898daa..b871d9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chronoscope", "version": "1.0.0", "dependencies": { + "idb": "^8.0.3", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -1772,6 +1773,12 @@ "dev": true, "license": "ISC" }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", diff --git a/package.json b/package.json index edcef8d..7e5fe54 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "idb": "^8.0.3", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/src/App.tsx b/src/App.tsx index 4435471..a4da7dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,21 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, } from 'lucide-react'; -import { ChronoscopeProvider } from './context/ChronoscopeContext'; +import { ChronoscopeProvider, useChronoscope } from './context/ChronoscopeContext'; import { Header, ControlPlane, Viewport, DataStream, Waypoints, + TemporalJournal, } from './components'; +import { getCoordinatesFromUrl, updateUrlWithCoordinates } from './utils/urlManager'; +import { addJournalEntry } from './utils/temporalJournal'; interface ChronoscopeAppProps { onApiKeyChange: () => void; @@ -22,6 +25,36 @@ function ChronoscopeApp({ onApiKeyChange }: ChronoscopeAppProps) { const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(true); const [mobileTab, setMobileTab] = useState<'controls' | 'viewport' | 'data'>('viewport'); + const { state, setCoordinates, renderScene } = useChronoscope(); + + // Read URL coordinates on mount and auto-render + useEffect(() => { + const urlCoords = getCoordinatesFromUrl(); + if (urlCoords) { + setCoordinates(urlCoords); + // Small delay to ensure state is set before rendering + setTimeout(() => { + renderScene(); + }, 100); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update URL and save to journal when scene is rendered + useEffect(() => { + if (state.currentScene) { + updateUrlWithCoordinates(state.currentScene.coordinates); + + // Save to journal + addJournalEntry( + state.currentScene.coordinates, + state.currentScene.locationName, + !!state.generatedImage + ); + + // Notify journal component to refresh + window.dispatchEvent(new Event('journalUpdated')); + } + }, [state.currentScene]); // eslint-disable-line react-hooks/exhaustive-deps return (
@@ -54,6 +87,7 @@ function ChronoscopeApp({ onApiKeyChange }: ChronoscopeAppProps) {
+
)}
@@ -134,6 +168,7 @@ function ChronoscopeApp({ onApiKeyChange }: ChronoscopeAppProps) {
+
)} {mobileTab === 'viewport' && } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 281c4ec..5a0645d 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,9 +5,16 @@ import { Info, X, Github, + Share2, + Check, + Images, } from 'lucide-react'; import { Settings } from './Settings'; +import { ImageGallery } from './ImageGallery'; import { isGeminiConfigured } from '../services/geminiService'; +import { getGalleryCount } from '../services/galleryService'; +import { useChronoscope } from '../context/ChronoscopeContext'; +import { copyShareableUrl } from '../utils/urlManager'; interface HeaderProps { onApiKeyChange?: () => void; @@ -17,7 +24,20 @@ export function Header({ onApiKeyChange }: HeaderProps) { const [currentTime, setCurrentTime] = useState(new Date()); const [showInfo, setShowInfo] = useState(false); const [showSettings, setShowSettings] = useState(false); + const [showGallery, setShowGallery] = useState(false); const [apiConfigured, setApiConfigured] = useState(isGeminiConfigured()); + const [galleryCount, setGalleryCount] = useState(0); + const [copied, setCopied] = useState(false); + const { state } = useChronoscope(); + + const handleShare = async () => { + if (!state.currentScene) return; + const success = await copyShareableUrl(state.currentScene.coordinates); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; // Update real-world time every second useEffect(() => { @@ -27,6 +47,19 @@ export function Header({ onApiKeyChange }: HeaderProps) { return () => clearInterval(timer); }, []); + // Load gallery count on mount and listen for updates + useEffect(() => { + const loadCount = async () => { + const count = await getGalleryCount(); + setGalleryCount(count); + }; + loadCount(); + + // Listen for gallery update events + window.addEventListener('galleryUpdated', loadCount); + return () => window.removeEventListener('galleryUpdated', loadCount); + }, []); + return ( <>
@@ -75,6 +108,32 @@ export function Header({ onApiKeyChange }: HeaderProps) { {/* Actions */}
+ + + + {/* Info overlay */} +
+

+ {image.locationName} +

+

{year}

+
+ + {/* Action buttons - visible on hover */} +
+ + +
+
+ ); +} + +interface ImageViewerProps { + image: GalleryImage; + onBack: () => void; + onDelete: () => void; + onJumpTo: () => void; +} + +function ImageViewer({ image, onBack, onDelete, onJumpTo }: ImageViewerProps) { + const { temporal } = image.coordinates; + + return ( +
+ {/* Header */} +
+ +
+ + +
+
+ + {/* Image and Info */} +
+ {/* Image */} +
+ {image.locationName} +
+ + {/* Info panel */} +
+
+

+ {image.locationName} +

+

+ {image.description} +

+
+ +
+
+ +
+

+ Coordinates +

+

+ {image.coordinates.spatial.latitude.toFixed(4)}°,{' '} + {image.coordinates.spatial.longitude.toFixed(4)}° +

+
+
+ +
+ +
+

+ Date & Time +

+

+ {temporal.month}/{temporal.day}/{formatYear(temporal.year)} +

+

+ {temporal.hour.toString().padStart(2, '0')}: + {temporal.minute.toString().padStart(2, '0')} +

+
+
+
+ + +
+
+
+ ); +} + +export function ImageGallery({ isOpen, onClose }: ImageGalleryProps) { + const { jumpToWaypoint } = useChronoscope(); + const [images, setImages] = useState([]); + const [selectedImage, setSelectedImage] = useState(null); + const [storageInfo, setStorageInfo] = useState(''); + const [showConfirmClear, setShowConfirmClear] = useState(false); + const [loading, setLoading] = useState(true); + + // Load images when modal opens + useEffect(() => { + if (isOpen) { + loadImages(); + } + }, [isOpen]); + + const loadImages = async () => { + setLoading(true); + try { + const galleryImages = await getAllGalleryImages(); + setImages(galleryImages); + const storage = await estimateStorageUsage(); + setStorageInfo(storage.formatted); + } catch (error) { + console.error('Failed to load gallery:', error); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + await deleteGalleryImage(id); + setImages((prev) => prev.filter((img) => img.id !== id)); + if (selectedImage?.id === id) { + setSelectedImage(null); + } + const storage = await estimateStorageUsage(); + setStorageInfo(storage.formatted); + }; + + const handleClearAll = async () => { + await clearGallery(); + setImages([]); + setSelectedImage(null); + setShowConfirmClear(false); + setStorageInfo('0 B'); + }; + + const handleJumpTo = (image: GalleryImage) => { + jumpToWaypoint(image.coordinates); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {selectedImage ? ( + setSelectedImage(null)} + onDelete={() => handleDelete(selectedImage.id)} + onJumpTo={() => handleJumpTo(selectedImage)} + /> + ) : ( + <> + {/* Header */} +
+
+ +

+ Image Gallery +

+ {images.length > 0 && ( + + {images.length} + + )} +
+ +
+ + {/* Storage info */} + {images.length > 0 && ( +
+ + + Storage used: {storageInfo} + +
+ )} + + {/* Content */} +
+ {loading ? ( +
+
+
+ ) : images.length === 0 ? ( +
+ +

+ No images saved yet. +

+

+ Generate an image from a rendered scene to add it here. +

+
+ ) : ( +
+ {images.map((image) => ( + setSelectedImage(image)} + onDelete={() => handleDelete(image.id)} + onDownload={() => downloadImage(image)} + /> + ))} +
+ )} +
+ + {/* Footer */} + {images.length > 0 && ( +
+ {showConfirmClear ? ( +
+ + +
+ ) : ( + + )} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/TemporalJournal.tsx b/src/components/TemporalJournal.tsx new file mode 100644 index 0000000..04298c0 --- /dev/null +++ b/src/components/TemporalJournal.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useRef } from 'react'; +import { + ScrollText, + Trash2, + Download, + Upload, + X, + MapPin, + Clock, + Image as ImageIcon, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { useChronoscope } from '../context/ChronoscopeContext'; +import type { JournalEntry } from '../types'; +import { + getJournal, + removeJournalEntry, + clearJournal, + exportJournal, + importJournal, + formatTimestamp, +} from '../utils/temporalJournal'; +import { formatYear } from '../utils/validation'; + +interface JournalEntryCardProps { + entry: JournalEntry; + onSelect: () => void; + onDelete: () => void; + isSelected: boolean; +} + +function JournalEntryCard({ entry, onSelect, onDelete, isSelected }: JournalEntryCardProps) { + const { temporal } = entry.coordinates; + const formattedDate = `${temporal.month}/${temporal.day}/${formatYear(temporal.year)}`; + const formattedTime = `${temporal.hour.toString().padStart(2, '0')}:${temporal.minute.toString().padStart(2, '0')}`; + + return ( +
+ + + {/* Delete button - visible on hover */} + +
+ ); +} + +export function TemporalJournal() { + const { jumpToWaypoint } = useChronoscope(); + const [entries, setEntries] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [isExpanded, setIsExpanded] = useState(true); + const [showConfirmClear, setShowConfirmClear] = useState(false); + const fileInputRef = useRef(null); + + // Load journal entries on mount and listen for updates + useEffect(() => { + const loadEntries = () => { + const journal = getJournal(); + setEntries(journal.entries); + }; + + loadEntries(); + + // Listen for storage events (for cross-tab sync) + window.addEventListener('storage', loadEntries); + + // Listen for custom journal update events + window.addEventListener('journalUpdated', loadEntries); + + return () => { + window.removeEventListener('storage', loadEntries); + window.removeEventListener('journalUpdated', loadEntries); + }; + }, []); + + const handleSelect = (entry: JournalEntry) => { + setSelectedId(entry.id); + jumpToWaypoint(entry.coordinates); + }; + + const handleDelete = (id: string) => { + removeJournalEntry(id); + setEntries((prev) => prev.filter((e) => e.id !== id)); + if (selectedId === id) { + setSelectedId(null); + } + }; + + const handleClearAll = () => { + clearJournal(); + setEntries([]); + setSelectedId(null); + setShowConfirmClear(false); + }; + + const handleExport = () => { + exportJournal(); + }; + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const count = await importJournal(file); + const journal = getJournal(); + setEntries(journal.entries); + alert(`Imported ${count} new entries`); + } catch (error) { + alert('Failed to import journal. Please check the file format.'); + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + if (entries.length === 0) { + return ( +
+ {/* Header */} +
+ +

+ Temporal Journal +

+
+ + {/* Empty state */} +
+ +

+ No visits recorded yet. +

+

+ Render a scene to start your journey. +

+
+ + {/* Import button */} +
+ + +
+
+ ); + } + + return ( +
+ {/* Header */} + + + {isExpanded && ( + <> + {/* Entry list */} +
+ {entries.map((entry) => ( + handleSelect(entry)} + onDelete={() => handleDelete(entry.id)} + isSelected={selectedId === entry.id} + /> + ))} +
+ + {/* Actions */} +
+
+ + + +
+ + {showConfirmClear ? ( +
+ + +
+ ) : ( + + )} +
+ + )} +
+ ); +} diff --git a/src/components/Viewport.tsx b/src/components/Viewport.tsx index 6ff9d82..0ec8ee5 100644 --- a/src/components/Viewport.tsx +++ b/src/components/Viewport.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import { Compass, Mountain, @@ -10,6 +10,7 @@ import { Sparkles, } from 'lucide-react'; import { useChronoscope } from '../context/ChronoscopeContext'; +import { saveGalleryImage } from '../services/galleryService'; import type { HazardLevel } from '../types'; // Generate random stars for the background @@ -88,6 +89,7 @@ export function Viewport() { const [compassRotation, setCompassRotation] = useState(0); const [showImage, setShowImage] = useState(true); const stars = useMemo(() => generateStars(50), []); + const lastSavedImage = useRef(null); // Animate compass slightly useEffect(() => { @@ -97,6 +99,28 @@ export function Viewport() { return () => clearInterval(interval); }, []); + // Auto-save generated images to gallery + useEffect(() => { + const saveToGallery = async () => { + if (generatedImage && currentScene && generatedImage !== lastSavedImage.current) { + try { + await saveGalleryImage( + generatedImage, + currentScene.coordinates, + currentScene.locationName, + currentScene.description + ); + lastSavedImage.current = generatedImage; + // Notify header to update gallery count + window.dispatchEvent(new Event('galleryUpdated')); + } catch (error) { + console.error('Failed to save image to gallery:', error); + } + } + }; + saveToGallery(); + }, [generatedImage, currentScene]); + const hazardLevel = currentScene?.safety.hazardLevel || 'low'; const colors = getHazardColors(hazardLevel); const hour = currentScene?.coordinates.temporal.hour ?? inputCoordinates.temporal.hour; diff --git a/src/components/index.ts b/src/components/index.ts index 80ca39a..0bab47a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,3 +4,5 @@ export { Viewport } from './Viewport'; export { DataStream } from './DataStream'; export { Waypoints } from './Waypoints'; export { Settings } from './Settings'; +export { TemporalJournal } from './TemporalJournal'; +export { ImageGallery } from './ImageGallery'; diff --git a/src/services/galleryService.ts b/src/services/galleryService.ts new file mode 100644 index 0000000..91b92a9 --- /dev/null +++ b/src/services/galleryService.ts @@ -0,0 +1,166 @@ +import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; +import type { GalleryImage, SpacetimeCoordinates } from '../types'; + +interface GalleryDB extends DBSchema { + images: { + key: string; + value: GalleryImage; + indexes: { 'by-timestamp': number }; + }; +} + +const DB_NAME = 'chronoscope-gallery'; +const DB_VERSION = 1; +const STORE_NAME = 'images'; + +let dbPromise: Promise> | null = null; + +/** + * Get or initialize the database connection + */ +function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('by-timestamp', 'timestamp'); + } + }, + }); + } + return dbPromise; +} + +/** + * Save an image to the gallery + */ +export async function saveGalleryImage( + imageData: string, + coordinates: SpacetimeCoordinates, + locationName: string, + description: string +): Promise { + const db = await getDB(); + + const newImage: GalleryImage = { + id: crypto.randomUUID(), + imageData, + coordinates, + locationName, + description, + timestamp: Date.now(), + }; + + await db.add(STORE_NAME, newImage); + return newImage; +} + +/** + * Get all images from the gallery, sorted by timestamp (newest first) + */ +export async function getAllGalleryImages(): Promise { + const db = await getDB(); + const images = await db.getAllFromIndex(STORE_NAME, 'by-timestamp'); + // Return in reverse order (newest first) + return images.reverse(); +} + +/** + * Get a single image by ID + */ +export async function getGalleryImage(id: string): Promise { + const db = await getDB(); + return db.get(STORE_NAME, id); +} + +/** + * Delete an image from the gallery + */ +export async function deleteGalleryImage(id: string): Promise { + const db = await getDB(); + await db.delete(STORE_NAME, id); +} + +/** + * Clear all images from the gallery + */ +export async function clearGallery(): Promise { + const db = await getDB(); + await db.clear(STORE_NAME); +} + +/** + * Get the count of images in the gallery + */ +export async function getGalleryCount(): Promise { + const db = await getDB(); + return db.count(STORE_NAME); +} + +/** + * Estimate storage usage (rough estimate based on image data sizes) + */ +export async function estimateStorageUsage(): Promise<{ used: number; formatted: string }> { + const images = await getAllGalleryImages(); + let totalBytes = 0; + + for (const image of images) { + // Base64 is roughly 4/3 the size of the original binary data + // Each character in a base64 string is 1 byte in JavaScript strings + totalBytes += image.imageData.length; + // Add some overhead for metadata + totalBytes += JSON.stringify(image.coordinates).length; + totalBytes += image.locationName.length; + totalBytes += image.description.length; + } + + const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return { + used: totalBytes, + formatted: formatBytes(totalBytes), + }; +} + +/** + * Download an image as PNG + */ +export function downloadImage(image: GalleryImage): void { + const { coordinates, locationName } = image; + const { temporal } = coordinates; + + // Create filename from location and date + const year = + temporal.year < 0 ? `${Math.abs(temporal.year)}BC` : `${temporal.year}`; + const safeName = locationName + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/-+/g, '-') + .substring(0, 30); + const filename = `chronoscope-${safeName}-${year}.png`; + + // Create download link + const link = document.createElement('a'); + link.href = image.imageData; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Format coordinates for display + */ +export function formatCoordinatesDisplay(coordinates: SpacetimeCoordinates): string { + const { spatial, temporal } = coordinates; + const lat = spatial.latitude.toFixed(4); + const lng = spatial.longitude.toFixed(4); + const year = + temporal.year < 0 ? `${Math.abs(temporal.year)} BC` : `${temporal.year} AD`; + + return `${lat}°, ${lng}° | ${temporal.month}/${temporal.day}/${year}`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0289c26..3470b69 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -141,3 +141,28 @@ export interface DateValidation { isValid: boolean; error?: string; } + +// Temporal Journal types +export interface JournalEntry { + id: string; + coordinates: SpacetimeCoordinates; + locationName: string; + timestamp: number; // Unix timestamp when visited + hasGeneratedImage: boolean; + thumbnail?: string; // Small base64 preview +} + +export interface TemporalJournal { + entries: JournalEntry[]; + maxEntries: number; +} + +// Image Gallery types (stored in IndexedDB) +export interface GalleryImage { + id: string; + imageData: string; // base64 data URL + coordinates: SpacetimeCoordinates; + locationName: string; + description: string; + timestamp: number; // Unix timestamp when saved +} diff --git a/src/utils/temporalJournal.ts b/src/utils/temporalJournal.ts new file mode 100644 index 0000000..eb416ae --- /dev/null +++ b/src/utils/temporalJournal.ts @@ -0,0 +1,208 @@ +import type { JournalEntry, TemporalJournal, SpacetimeCoordinates } from '../types'; + +const JOURNAL_KEY = 'chronoscope_journal'; +const MAX_ENTRIES = 50; + +/** + * Get the journal from localStorage + */ +export function getJournal(): TemporalJournal { + try { + const stored = localStorage.getItem(JOURNAL_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { + entries: parsed.entries || [], + maxEntries: parsed.maxEntries || MAX_ENTRIES, + }; + } + } catch (e) { + console.error('Failed to load journal:', e); + } + return { entries: [], maxEntries: MAX_ENTRIES }; +} + +/** + * Save the journal to localStorage + */ +function saveJournal(journal: TemporalJournal): void { + try { + localStorage.setItem(JOURNAL_KEY, JSON.stringify(journal)); + } catch (e) { + console.error('Failed to save journal:', e); + } +} + +/** + * Add a new journal entry + */ +export function addJournalEntry( + coordinates: SpacetimeCoordinates, + locationName: string, + hasGeneratedImage: boolean = false, + thumbnail?: string +): JournalEntry { + const journal = getJournal(); + + const newEntry: JournalEntry = { + id: crypto.randomUUID(), + coordinates, + locationName, + timestamp: Date.now(), + hasGeneratedImage, + thumbnail, + }; + + // Add to the beginning (most recent first) + journal.entries.unshift(newEntry); + + // Trim to max entries + if (journal.entries.length > journal.maxEntries) { + journal.entries = journal.entries.slice(0, journal.maxEntries); + } + + saveJournal(journal); + return newEntry; +} + +/** + * Update an existing journal entry (e.g., when image is generated) + */ +export function updateJournalEntry( + id: string, + updates: Partial> +): JournalEntry | null { + const journal = getJournal(); + const index = journal.entries.findIndex((e) => e.id === id); + + if (index === -1) return null; + + journal.entries[index] = { + ...journal.entries[index], + ...updates, + }; + + saveJournal(journal); + return journal.entries[index]; +} + +/** + * Remove a journal entry by ID + */ +export function removeJournalEntry(id: string): boolean { + const journal = getJournal(); + const originalLength = journal.entries.length; + journal.entries = journal.entries.filter((e) => e.id !== id); + + if (journal.entries.length !== originalLength) { + saveJournal(journal); + return true; + } + return false; +} + +/** + * Clear all journal entries + */ +export function clearJournal(): void { + const journal: TemporalJournal = { entries: [], maxEntries: MAX_ENTRIES }; + saveJournal(journal); +} + +/** + * Export journal as JSON file + */ +export function exportJournal(): void { + const journal = getJournal(); + const blob = new Blob([JSON.stringify(journal, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chronoscope-journal-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Import journal from JSON file + * Returns the number of entries imported + */ +export function importJournal(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const imported = JSON.parse(content) as TemporalJournal; + + if (!imported.entries || !Array.isArray(imported.entries)) { + throw new Error('Invalid journal format'); + } + + // Validate entries have required fields + const validEntries = imported.entries.filter( + (entry) => + entry.id && + entry.coordinates && + entry.locationName && + entry.timestamp + ); + + const journal = getJournal(); + + // Merge entries, avoiding duplicates by ID + const existingIds = new Set(journal.entries.map((e) => e.id)); + const newEntries = validEntries.filter((e) => !existingIds.has(e.id)); + + journal.entries = [...newEntries, ...journal.entries] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, journal.maxEntries); + + saveJournal(journal); + resolve(newEntries.length); + } catch (error) { + reject(error); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); +} + +/** + * Format coordinates for display + */ +export function formatCoordinatesShort(coordinates: SpacetimeCoordinates): string { + const { spatial, temporal } = coordinates; + const lat = spatial.latitude.toFixed(2); + const lng = spatial.longitude.toFixed(2); + const year = temporal.year < 0 ? `${Math.abs(temporal.year)} BC` : `${temporal.year}`; + return `${lat}°, ${lng}° | ${year}`; +} + +/** + * Format timestamp for display + */ +export function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +} diff --git a/src/utils/urlManager.ts b/src/utils/urlManager.ts new file mode 100644 index 0000000..e2c64a5 --- /dev/null +++ b/src/utils/urlManager.ts @@ -0,0 +1,121 @@ +import type { SpacetimeCoordinates } from '../types'; + +/** + * URL-based coordinate sharing utility + * Encodes and decodes spacetime coordinates to/from URL query parameters + */ + +/** + * Encode spacetime coordinates into URL query string + */ +export function encodeCoordinates(coords: SpacetimeCoordinates): string { + const params = new URLSearchParams(); + params.set('lat', coords.spatial.latitude.toString()); + params.set('lng', coords.spatial.longitude.toString()); + params.set('year', coords.temporal.year.toString()); + params.set('month', coords.temporal.month.toString()); + params.set('day', coords.temporal.day.toString()); + params.set('hour', coords.temporal.hour.toString()); + params.set('minute', coords.temporal.minute.toString()); + return params.toString(); +} + +/** + * Decode spacetime coordinates from URL query string + * Returns null if required parameters are missing + */ +export function decodeCoordinates(search: string): SpacetimeCoordinates | null { + const params = new URLSearchParams(search); + + // Check if we have at least lat/lng/year (minimum required) + if (!params.has('lat') || !params.has('lng') || !params.has('year')) { + return null; + } + + const lat = parseFloat(params.get('lat')!); + const lng = parseFloat(params.get('lng')!); + const year = parseInt(params.get('year')!, 10); + + // Validate parsed values + if (isNaN(lat) || isNaN(lng) || isNaN(year)) { + return null; + } + + // Use defaults for optional time components + const month = params.has('month') ? parseInt(params.get('month')!, 10) : 1; + const day = params.has('day') ? parseInt(params.get('day')!, 10) : 1; + const hour = params.has('hour') ? parseInt(params.get('hour')!, 10) : 12; + const minute = params.has('minute') ? parseInt(params.get('minute')!, 10) : 0; + + return { + spatial: { + latitude: lat, + longitude: lng, + }, + temporal: { + year, + month: isNaN(month) ? 1 : month, + day: isNaN(day) ? 1 : day, + hour: isNaN(hour) ? 12 : hour, + minute: isNaN(minute) ? 0 : minute, + }, + }; +} + +/** + * Update the browser URL with coordinates (without page reload) + */ +export function updateUrlWithCoordinates(coords: SpacetimeCoordinates): void { + const queryString = encodeCoordinates(coords); + const newUrl = `${window.location.pathname}?${queryString}`; + window.history.replaceState({}, '', newUrl); +} + +/** + * Get the current URL's coordinates, if any + */ +export function getCoordinatesFromUrl(): SpacetimeCoordinates | null { + return decodeCoordinates(window.location.search); +} + +/** + * Generate a shareable URL for the given coordinates + */ +export function generateShareableUrl(coords: SpacetimeCoordinates): string { + const queryString = encodeCoordinates(coords); + return `${window.location.origin}${window.location.pathname}?${queryString}`; +} + +/** + * Copy the shareable URL to clipboard + * Returns true on success, false on failure + */ +export async function copyShareableUrl(coords: SpacetimeCoordinates): Promise { + const url = generateShareableUrl(coords); + try { + await navigator.clipboard.writeText(url); + return true; + } catch { + // Fallback for older browsers + try { + const textArea = document.createElement('textarea'); + textArea.value = url; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + return true; + } catch { + return false; + } + } +} + +/** + * Clear coordinates from URL + */ +export function clearUrlCoordinates(): void { + window.history.replaceState({}, '', window.location.pathname); +}