From a6e0ad98bc3fa99d8027f22149d58488e4dcd87d Mon Sep 17 00:00:00 2001 From: Sem Pruijs Date: Wed, 14 Jan 2026 22:08:41 +0100 Subject: [PATCH 01/12] Refactor --- site/src/lib/app.ts | 171 +++++++++------- site/src/lib/components/AppContainer.svelte | 182 ++++++++++-------- site/src/lib/components/tabs/Tab.svelte | 10 +- site/src/lib/components/tabs/TabBar.svelte | 8 +- .../src/lib/components/tabs/TabContent.svelte | 12 +- 5 files changed, 219 insertions(+), 164 deletions(-) diff --git a/site/src/lib/app.ts b/site/src/lib/app.ts index ea0e048..d8ee678 100644 --- a/site/src/lib/app.ts +++ b/site/src/lib/app.ts @@ -4,7 +4,7 @@ import { BibleBookSchema, getDisplayName as getBibleBookDisplayName, getShortNam import type { Translation } from "$lib/translations/translation"; import { TranslationSchema } from "$lib/translations/translation"; -// Effect Schema definitions - App content without IDs +// Effect Schema definitions - App-specific state export const BibleStateSchema = Schema.Struct({ currentBook: BibleBookSchema, currentChapter: Schema.Number, @@ -17,8 +17,8 @@ export const StopwatchStateSchema = Schema.Struct({ isRunning: Schema.Boolean }); -// App content schema - just the app-specific data -export const AppContentSchema = Schema.Union( +// App schema - represents the application running in a tab +export const AppSchema = Schema.Union( Schema.Struct({ _tag: Schema.Literal("Bible"), bibleState: BibleStateSchema @@ -35,28 +35,50 @@ export const AppContentSchema = Schema.Union( }) ); -// Tab schema - combines ID with app content -export const TabSchema = Schema.Struct({ +// TabState schema - combines ID with app +export const TabStateSchema = Schema.Struct({ id: Schema.String, - app: AppContentSchema + app: AppSchema }); -// Type exports -export type BibleState = Schema.Schema.Type; -export type StopwatchState = Schema.Schema.Type; -export type AppContent = Schema.Schema.Type; -export type Tab = Schema.Schema.Type; +// TabsState schema - the complete tabs system state +export const TabsStateSchema = Schema.Struct({ + tabs: Schema.Array(TabStateSchema), + activeTabId: Schema.String, + nextTabId: Schema.Number +}); + +// Helper type to make deeply readonly types writable (for Svelte $state compatibility) +type DeepWritable = T extends object + ? { -readonly [P in keyof T]: DeepWritable } + : T; +// Type exports - Schema.Type generates readonly types, make them writable for $state +type BibleStateReadonly = Schema.Schema.Type; +type StopwatchStateReadonly = Schema.Schema.Type; +type AppReadonly = Schema.Schema.Type; +type TabStateReadonly = Schema.Schema.Type; +type TabsStateReadonly = Schema.Schema.Type; + +export type BibleState = DeepWritable; +export type StopwatchState = DeepWritable; +export type App = DeepWritable; +export type TabState = DeepWritable; +export type TabsState = DeepWritable; // Maintain backward compatibility with Data constructors export const BibleState = Data.case(); export const StopwatchState = Data.case(); -// AppContent constructors -export const { Bible, About, ChooseApp, Stopwatch, $match } = Data.taggedEnum() +// App constructors +export const { Bible, About, ChooseApp, Stopwatch, $match } = Data.taggedEnum() + +// Legacy type aliases for backward compatibility (will be removed later) +export type AppContent = App; +export type Tab = TabState; -// Tab ID management using Effect -export const getTabId = (tab: Tab): string => { +// Selectors - extract information from TabState +export const getTabId = (tab: TabState): string => { return tab.id; }; @@ -68,8 +90,8 @@ const formatTimeForTitle = (milliseconds: number): string => { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }; -// Get display name for Tab (tab title) using $match -export const getDisplayName = (tab: Tab): string => { +// Get display name for TabState (tab title) using $match +export const getDisplayName = (tab: TabState): string => { return $match(tab.app, { Bible: ({ bibleState }) => `${getBibleBookDisplayName(bibleState.currentBook)} ${bibleState.currentChapter} (${bibleState.translation.metadata.shortName})`, About: () => "About", @@ -83,62 +105,79 @@ export const getDisplayName = (tab: Tab): string => { }); }; -// Tab state transformations using Effect -export const transformTab = (tab: Tab, transform: (tab: Tab) => Tab): Effect.Effect => - Effect.succeed(transform(tab)); +// Map TabState instances to their corresponding URLs +export const getTabUrl = (tab: TabState): string => { + return $match(tab.app, { + Bible: ({ bibleState }) => { + const bookShort = getShortName(bibleState.currentBook); + return `/${bookShort}/${bibleState.currentChapter}`; + }, + About: () => "/about", + ChooseApp: () => "/", // Default to home for choose app + Stopwatch: () => "/stopwatch" + }); +}; + +// Create tab configuration types +export type CreateTabConfig = + | { app: "Bible", id: string, book: BibleBook, chapter: number, translation: Translation, showCanonExplorer: boolean } + | { app: "Stopwatch", id: string } + | { app: "About", id: string } + | { app: "ChooseApp", id: string }; + +// Unified tab creation function +export const createTab = (config: CreateTabConfig): Effect.Effect => { + switch (config.app) { + case "Bible": + return Effect.succeed({ + id: config.id, + app: Bible({ + bibleState: BibleState({ + currentBook: config.book, + currentChapter: config.chapter, + translation: config.translation as any, // Translation types are readonly but compatible + showCanonExplorer: config.showCanonExplorer + }) + }) + }); + case "Stopwatch": + return Effect.succeed({ + id: config.id, + app: Stopwatch({ + stopwatchState: StopwatchState({ + elapsedTime: 0, + isRunning: false + }) + }) + }); + case "About": + return Effect.succeed({ + id: config.id, + app: About() + }); + case "ChooseApp": + return Effect.succeed({ + id: config.id, + app: ChooseApp() + }); + } +}; +// Legacy functions for backward compatibility (will be removed later) export const createBibleTab = ( id: string, book: BibleBook, chapter: number, translation: Translation, showCanonExplorer: boolean -): Effect.Effect => - Effect.succeed({ - id, - app: Bible({ - bibleState: BibleState({ - currentBook: book, - currentChapter: chapter, - translation, - showCanonExplorer - }) - }) - }); - -export const createAboutTab = (id: string): Effect.Effect => - Effect.succeed({ - id, - app: About() - }); - -export const createChooseTab = (id: string): Effect.Effect => - Effect.succeed({ - id, - app: ChooseApp() - }); +): Effect.Effect => + createTab({ app: "Bible", id, book, chapter, translation, showCanonExplorer }); -export const createStopwatchTab = (id: string): Effect.Effect => - Effect.succeed({ - id, - app: Stopwatch({ - stopwatchState: StopwatchState({ - elapsedTime: 0, - isRunning: false - }) - }) - }); +export const createAboutTab = (id: string): Effect.Effect => + createTab({ app: "About", id }); +export const createChooseTab = (id: string): Effect.Effect => + createTab({ app: "ChooseApp", id }); -// Map Tab instances to their corresponding URLs -export const getTabUrl = (tab: Tab): string => { - return $match(tab.app, { - Bible: ({ bibleState }) => { - const bookShort = getShortName(bibleState.currentBook); - return `/${bookShort}/${bibleState.currentChapter}`; - }, - About: () => "/about", - ChooseApp: () => "/", // Default to home for choose app - Stopwatch: () => "/stopwatch" - }); -}; \ No newline at end of file +export const createStopwatchTab = (id: string): Effect.Effect => + createTab({ app: "Stopwatch", id }); \ No newline at end of file diff --git a/site/src/lib/components/AppContainer.svelte b/site/src/lib/components/AppContainer.svelte index 5001ac8..fa726b5 100644 --- a/site/src/lib/components/AppContainer.svelte +++ b/site/src/lib/components/AppContainer.svelte @@ -2,14 +2,12 @@ import type { Translation } from "$lib/translations/translation"; import { BibleBook } from "$lib/book"; import { - type Tab, + type TabsState, + type TabState, getTabId, getTabUrl, getDisplayName, - createBibleTab, - createAboutTab, - createChooseTab, - createStopwatchTab + createTab } from "$lib/app"; import { NavigationServiceLive } from "$lib/services/NavigationService"; import { ResponsiveServiceLive } from "$lib/services/ResponsiveService"; @@ -21,50 +19,60 @@ let { translation }: { translation: Translation } = $props(); - // State management - let tabs = $state([]); - let activeTabId = $state("tab1"); - let nextTabId = $state(2); + // State management - single source of truth + let tabsState = $state({ + tabs: [], + activeTabId: "tab1", + nextTabId: 2 + }); // Initialize the first tab using Effect async function initializeTab() { try { const initialState = await Effect.runPromise(NavigationServiceLive.getInitialState()); - - let initialTab: Tab; + + let initialTab: TabState; if (initialState.isAbout) { // Create About tab if URL is /about - initialTab = await Effect.runPromise(createAboutTab("tab1")); + initialTab = await Effect.runPromise(createTab({ app: "About", id: "tab1" })); } else if (initialState.isStopwatch) { // Create Stopwatch tab if URL is /stopwatch - initialTab = await Effect.runPromise(createStopwatchTab("tab1")); + initialTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: "tab1" })); } else { // Create Bible tab with parsed book/chapter const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); initialTab = await Effect.runPromise( - createBibleTab( - "tab1", - initialState.book, - initialState.chapter, - translation, - canonState - ) + createTab({ + app: "Bible", + id: "tab1", + book: initialState.book, + chapter: initialState.chapter, + translation, + showCanonExplorer: canonState + }) ); } - - tabs = [initialTab]; + + tabsState.tabs = [initialTab]; } catch (error) { console.error("Failed to initialize tab:", error); // Fallback to default const fallbackTab = await Effect.runPromise( - createBibleTab("tab1", BibleBook.John, 1, translation, true) + createTab({ + app: "Bible", + id: "tab1", + book: BibleBook.John, + chapter: 1, + translation, + showCanonExplorer: true + }) ); - tabs = [fallbackTab]; + tabsState.tabs = [fallbackTab]; } } - // Get active tab reference - let activeTab = $derived(tabs.find(tab => getTabId(tab) === activeTabId)); + // Get active tab reference - derived from tabsState + let activeTab = $derived(tabsState.tabs.find(tab => getTabId(tab) === tabsState.activeTabId)); // Update browser title when active tab changes $effect(() => { @@ -74,12 +82,12 @@ }); // Update tab state - function updateTabState(updatedTab: Tab) { + function updateTabState(updatedTab: TabState) { const tabId = getTabId(updatedTab); - tabs = tabs.map(tab => getTabId(tab) === tabId ? updatedTab : tab); - + tabsState.tabs = tabsState.tabs.map(tab => getTabId(tab) === tabId ? updatedTab : tab); + // Update URL if this is the active tab - if (tabId === activeTabId) { + if (tabId === tabsState.activeTabId) { const url = getTabUrl(updatedTab); Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); } @@ -88,11 +96,11 @@ // Add new tab async function addTab() { try { - const tabId = `tab${nextTabId}`; - const newTab = await Effect.runPromise(createChooseTab(tabId)); - tabs = [...tabs, newTab]; - activeTabId = tabId; - nextTabId++; + const tabId = `tab${tabsState.nextTabId}`; + const newTab = await Effect.runPromise(createTab({ app: "ChooseApp", id: tabId })); + tabsState.tabs = [...tabsState.tabs, newTab]; + tabsState.activeTabId = tabId; + tabsState.nextTabId++; } catch (error) { console.error("Failed to add tab:", error); } @@ -100,26 +108,26 @@ // Remove tab async function removeTab(tabId: string) { - if (tabs.length === 1) return; - + if (tabsState.tabs.length === 1) return; + // Clean up background tasks for the removed tab (no longer needed with simplified approach) - - tabs = tabs.filter(tab => getTabId(tab) !== tabId); - - if (activeTabId === tabId) { - const firstTab = tabs[0]; + + tabsState.tabs = tabsState.tabs.filter(tab => getTabId(tab) !== tabId); + + if (tabsState.activeTabId === tabId) { + const firstTab = tabsState.tabs[0]; if (firstTab) { - activeTabId = getTabId(firstTab); + tabsState.activeTabId = getTabId(firstTab); } } } // Set active tab function setActiveTab(tabId: string) { - activeTabId = tabId; - + tabsState.activeTabId = tabId; + // Update URL for any tab type using the mapping function - const tab = tabs.find(tab => getTabId(tab) === tabId); + const tab = tabsState.tabs.find(tab => getTabId(tab) === tabId); if (tab) { const url = getTabUrl(tab); Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); @@ -128,22 +136,22 @@ // Tab navigation functions function goToNextTab() { - const currentIndex = tabs.findIndex(tab => getTabId(tab) === activeTabId); - if (currentIndex !== -1 && currentIndex < tabs.length - 1) { - setActiveTab(getTabId(tabs[currentIndex + 1])); - } else if (tabs.length > 1) { + const currentIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); + if (currentIndex !== -1 && currentIndex < tabsState.tabs.length - 1) { + setActiveTab(getTabId(tabsState.tabs[currentIndex + 1])); + } else if (tabsState.tabs.length > 1) { // Wrap to first tab - setActiveTab(getTabId(tabs[0])); + setActiveTab(getTabId(tabsState.tabs[0])); } } function goToPreviousTab() { - const currentIndex = tabs.findIndex(tab => getTabId(tab) === activeTabId); + const currentIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); if (currentIndex > 0) { - setActiveTab(getTabId(tabs[currentIndex - 1])); - } else if (tabs.length > 1) { + setActiveTab(getTabId(tabsState.tabs[currentIndex - 1])); + } else if (tabsState.tabs.length > 1) { // Wrap to last tab - setActiveTab(getTabId(tabs[tabs.length - 1])); + setActiveTab(getTabId(tabsState.tabs[tabsState.tabs.length - 1])); } } @@ -166,32 +174,39 @@ goToPreviousTab(); } else if (event.key === 'w') { event.preventDefault(); - removeTab(activeTabId); + removeTab(tabsState.activeTabId); } } // Handle app choice in ChooseApp tabs async function handleAppChoice(appType: "bible" | "about" | "stopwatch") { try { - const tabIndex = tabs.findIndex(tab => getTabId(tab) === activeTabId); + const tabIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); if (tabIndex === -1) return; - let newTab: Tab; + let newTab: TabState; if (appType === "bible") { const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); newTab = await Effect.runPromise( - createBibleTab(activeTabId, BibleBook.John, 1, translation, canonState) + createTab({ + app: "Bible", + id: tabsState.activeTabId, + book: BibleBook.John, + chapter: 1, + translation, + showCanonExplorer: canonState + }) ); } else if (appType === "about") { - newTab = await Effect.runPromise(createAboutTab(activeTabId)); + newTab = await Effect.runPromise(createTab({ app: "About", id: tabsState.activeTabId })); } else if (appType === "stopwatch") { - newTab = await Effect.runPromise(createStopwatchTab(activeTabId)); + newTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: tabsState.activeTabId })); } else { throw new Error(`Unknown app type: ${appType}`); } - - tabs = tabs.map((tab, index) => index === tabIndex ? newTab : tab); - + + tabsState.tabs = tabsState.tabs.map((tab, index) => index === tabIndex ? newTab : tab); + // Update URL using the mapping function const url = getTabUrl(newTab); Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); @@ -209,20 +224,21 @@ const urlState = await Effect.runPromise( NavigationServiceLive.parseURL(currentPage.url.pathname) ); - + if (urlState && activeTab && activeTab.app._tag === "Bible") { const currentBook = activeTab.app.bibleState.currentBook; const currentChapter = activeTab.app.bibleState.currentChapter; - + if (currentBook !== urlState.book || currentChapter !== urlState.chapter) { const updatedTab = await Effect.runPromise( - createBibleTab( - activeTab.id, - urlState.book, - urlState.chapter, - activeTab.app.bibleState.translation, - activeTab.app.bibleState.showCanonExplorer - ) + createTab({ + app: "Bible", + id: activeTab.id, + book: urlState.book, + chapter: urlState.chapter, + translation: activeTab.app.bibleState.translation, + showCanonExplorer: activeTab.app.bibleState.showCanonExplorer + }) ); updateTabState(updatedTab); } @@ -234,10 +250,10 @@ } // Initialize on mount - onMount(async () => { - await initializeTab(); - await syncFromURL(); - + onMount(() => { + // Start async initialization (don't await in onMount) + initializeTab().then(() => syncFromURL()); + // Add global keyboard event listener if (typeof window !== 'undefined') { window.addEventListener('keydown', handleGlobalKeydown); @@ -253,17 +269,17 @@
- - - import type { Tab } from "$lib/app"; + import type { TabState } from "$lib/app"; import { Bible, BibleState, Stopwatch, StopwatchState } from "$lib/app"; import BibleComponent from "$lib/components/bible/BibleReader.svelte"; import ChooseAppComponent from "$lib/components/ui/ChooseApp.svelte"; import StopwatchComponent from "$lib/components/ui/Stopwatch.svelte"; - let { + let { app, onStateChange, onAppChoice - }: { - app: Tab; - onStateChange?: (app: Tab) => void; + }: { + app: TabState; + onStateChange?: (app: TabState) => void; onAppChoice?: (appType: "bible" | "about" | "stopwatch") => void; } = $props(); diff --git a/site/src/lib/components/tabs/TabBar.svelte b/site/src/lib/components/tabs/TabBar.svelte index 9ccdb19..9e32734 100644 --- a/site/src/lib/components/tabs/TabBar.svelte +++ b/site/src/lib/components/tabs/TabBar.svelte @@ -1,15 +1,15 @@ From 17143f4aa7693888e1a2d0df8ac64ee862521143 Mon Sep 17 00:00:00 2001 From: Sem Pruijs Date: Fri, 16 Jan 2026 23:34:23 +0100 Subject: [PATCH 02/12] refactor --- site/src/lib/app.ts | 175 +++++++--- site/src/lib/components/AppContainer.svelte | 316 +++++++++--------- .../lib/components/bible/BibleReader.svelte | 27 +- site/src/lib/components/tabs/TabBar.svelte | 10 +- .../src/lib/components/tabs/TabContent.svelte | 14 +- site/src/lib/components/ui/Stopwatch.svelte | 23 +- site/src/lib/services/NavigationService.ts | 10 +- site/src/routes/+layout.svelte | 5 +- site/src/routes/+layout.ts | 42 ++- site/src/routes/[book]/[chapter]/+page.ts | 6 +- 10 files changed, 348 insertions(+), 280 deletions(-) diff --git a/site/src/lib/app.ts b/site/src/lib/app.ts index d8ee678..fa84cae 100644 --- a/site/src/lib/app.ts +++ b/site/src/lib/app.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Schema } from "effect"; +import { Data, Effect, Schema, Option } from "effect"; import type { BibleBook } from "$lib/book"; import { BibleBookSchema, getDisplayName as getBibleBookDisplayName, getShortName } from "$lib/book"; import type { Translation } from "$lib/translations/translation"; @@ -73,15 +73,6 @@ export const StopwatchState = Data.case(); // App constructors export const { Bible, About, ChooseApp, Stopwatch, $match } = Data.taggedEnum() -// Legacy type aliases for backward compatibility (will be removed later) -export type AppContent = App; -export type Tab = TabState; - -// Selectors - extract information from TabState -export const getTabId = (tab: TabState): string => { - return tab.id; -}; - // Format time as MM:SS for tab title const formatTimeForTitle = (milliseconds: number): string => { const totalSeconds = Math.floor(milliseconds / 1000); @@ -90,33 +81,129 @@ const formatTimeForTitle = (milliseconds: number): string => { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }; -// Get display name for TabState (tab title) using $match -export const getDisplayName = (tab: TabState): string => { - return $match(tab.app, { - Bible: ({ bibleState }) => `${getBibleBookDisplayName(bibleState.currentBook)} ${bibleState.currentChapter} (${bibleState.translation.metadata.shortName})`, - About: () => "About", - ChooseApp: () => "Choose App", - Stopwatch: ({ stopwatchState }) => { - if (stopwatchState.elapsedTime > 0 || stopwatchState.isRunning) { - return formatTimeForTitle(stopwatchState.elapsedTime); +// App namespace - operations on App type +export namespace App { + export const getUrl = (app: App): string => { + return $match(app, { + Bible: ({ bibleState }) => { + const bookShort = getShortName(bibleState.currentBook); + return `/${bookShort}/${bibleState.currentChapter}`; + }, + About: () => "/about", + ChooseApp: () => "/", + Stopwatch: () => "/stopwatch" + }); + }; + + export const getTitle = (app: App): string => { + return $match(app, { + Bible: ({ bibleState }) => `${getBibleBookDisplayName(bibleState.currentBook)} ${bibleState.currentChapter} (${bibleState.translation.metadata.shortName})`, + About: () => "About", + ChooseApp: () => "Choose App", + Stopwatch: ({ stopwatchState }) => { + if (stopwatchState.elapsedTime > 0 || stopwatchState.isRunning) { + return formatTimeForTitle(stopwatchState.elapsedTime); + } + return "Stopwatch"; + } + }); + }; +} + +// TabsState namespace - operations on TabsState type +export namespace TabsState { + export const addTab = (state: TabsState, app: App): Effect.Effect => { + return Effect.sync(() => { + const tabId = `tab${state.nextTabId}`; + const newTab: TabState = { id: tabId, app }; + + return { + tabs: [...state.tabs, newTab], + activeTabId: tabId, + nextTabId: state.nextTabId + 1 + }; + }); + }; + + export const removeTab = (state: TabsState, tabId: string): Effect.Effect => { + return Effect.sync(() => { + // Don't allow removing last tab + if (state.tabs.length === 1) { + return state; } - return "Stopwatch"; - } - }); -}; -// Map TabState instances to their corresponding URLs -export const getTabUrl = (tab: TabState): string => { - return $match(tab.app, { - Bible: ({ bibleState }) => { - const bookShort = getShortName(bibleState.currentBook); - return `/${bookShort}/${bibleState.currentChapter}`; - }, - About: () => "/about", - ChooseApp: () => "/", // Default to home for choose app - Stopwatch: () => "/stopwatch" - }); -}; + const newTabs = state.tabs.filter(tab => tab.id !== tabId); + + // If removing active tab, switch to first tab + const newActiveTabId = state.activeTabId === tabId + ? newTabs[0]?.id ?? state.activeTabId + : state.activeTabId; + + return { + tabs: newTabs, + activeTabId: newActiveTabId, + nextTabId: state.nextTabId + }; + }); + }; + + export const setActiveTab = (state: TabsState, tabId: string): Effect.Effect => { + return Effect.sync(() => { + // Verify tab exists + const tabExists = state.tabs.some(tab => tab.id === tabId); + if (!tabExists) { + console.error(`Tab ${tabId} not found`); + return state; + } + + return { + ...state, + activeTabId: tabId + }; + }); + }; + + export const nextTab = (state: TabsState): Effect.Effect => { + return Effect.sync(() => { + const currentIndex = state.tabs.findIndex(tab => tab.id === state.activeTabId); + if (currentIndex === -1) return state; + + const nextIndex = (currentIndex + 1) % state.tabs.length; + return { + ...state, + activeTabId: state.tabs[nextIndex].id + }; + }); + }; + + export const previousTab = (state: TabsState): Effect.Effect => { + return Effect.sync(() => { + const currentIndex = state.tabs.findIndex(tab => tab.id === state.activeTabId); + if (currentIndex === -1) return state; + + const prevIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1; + return { + ...state, + activeTabId: state.tabs[prevIndex].id + }; + }); + }; + + export const updateTab = (state: TabsState, updatedTab: TabState): Effect.Effect => { + return Effect.sync(() => { + return { + ...state, + tabs: state.tabs.map(tab => tab.id === updatedTab.id ? updatedTab : tab) + }; + }); + }; + + export const getActiveTab = (state: TabsState): Option.Option => { + const tab = state.tabs.find(t => t.id === state.activeTabId); + return tab ? Option.some(tab) : Option.none(); + }; +} + // Create tab configuration types export type CreateTabConfig = @@ -163,21 +250,3 @@ export const createTab = (config: CreateTabConfig): Effect.Effect => { } }; -// Legacy functions for backward compatibility (will be removed later) -export const createBibleTab = ( - id: string, - book: BibleBook, - chapter: number, - translation: Translation, - showCanonExplorer: boolean -): Effect.Effect => - createTab({ app: "Bible", id, book, chapter, translation, showCanonExplorer }); - -export const createAboutTab = (id: string): Effect.Effect => - createTab({ app: "About", id }); - -export const createChooseTab = (id: string): Effect.Effect => - createTab({ app: "ChooseApp", id }); - -export const createStopwatchTab = (id: string): Effect.Effect => - createTab({ app: "Stopwatch", id }); \ No newline at end of file diff --git a/site/src/lib/components/AppContainer.svelte b/site/src/lib/components/AppContainer.svelte index fa726b5..69fbe52 100644 --- a/site/src/lib/components/AppContainer.svelte +++ b/site/src/lib/components/AppContainer.svelte @@ -4,10 +4,10 @@ import { type TabsState, type TabState, - getTabId, - getTabUrl, - getDisplayName, - createTab + App, + TabsState as TabsStateNS, + createTab, + ChooseApp } from "$lib/app"; import { NavigationServiceLive } from "$lib/services/NavigationService"; import { ResponsiveServiceLive } from "$lib/services/ResponsiveService"; @@ -15,7 +15,7 @@ import TabContent from "$lib/components/tabs/TabContent.svelte"; import { page } from "$app/stores"; import { onMount } from "svelte"; - import { Effect } from "effect"; + import { Effect, Option } from "effect"; let { translation }: { translation: Translation } = $props(); @@ -28,131 +28,125 @@ // Initialize the first tab using Effect async function initializeTab() { - try { - const initialState = await Effect.runPromise(NavigationServiceLive.getInitialState()); - - let initialTab: TabState; - if (initialState.isAbout) { - // Create About tab if URL is /about - initialTab = await Effect.runPromise(createTab({ app: "About", id: "tab1" })); - } else if (initialState.isStopwatch) { - // Create Stopwatch tab if URL is /stopwatch - initialTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: "tab1" })); - } else { - // Create Bible tab with parsed book/chapter - const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); - initialTab = await Effect.runPromise( - createTab({ - app: "Bible", - id: "tab1", - book: initialState.book, - chapter: initialState.chapter, - translation, - showCanonExplorer: canonState - }) - ); - } - - tabsState.tabs = [initialTab]; - } catch (error) { - console.error("Failed to initialize tab:", error); - // Fallback to default - const fallbackTab = await Effect.runPromise( + const initialState = await Effect.runPromise(NavigationServiceLive.getInitialState()) + .catch(error => { + console.error("Failed to get initial state:", error); + return { book: BibleBook.John, chapter: 1, isAbout: false, isStopwatch: false }; + }); + + let initialTab: TabState; + if (initialState.isAbout) { + // Create About tab if URL is /about + initialTab = await Effect.runPromise(createTab({ app: "About", id: "tab1" })); + } else if (initialState.isStopwatch) { + // Create Stopwatch tab if URL is /stopwatch + initialTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: "tab1" })); + } else { + // Create Bible tab with parsed book/chapter + const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); + initialTab = await Effect.runPromise( createTab({ app: "Bible", id: "tab1", - book: BibleBook.John, - chapter: 1, + book: initialState.book, + chapter: initialState.chapter, translation, - showCanonExplorer: true + showCanonExplorer: canonState }) ); - tabsState.tabs = [fallbackTab]; } + + tabsState.tabs = [initialTab]; } // Get active tab reference - derived from tabsState - let activeTab = $derived(tabsState.tabs.find(tab => getTabId(tab) === tabsState.activeTabId)); + let activeTabOption = $derived(TabsStateNS.getActiveTab(tabsState)); // Update browser title when active tab changes $effect(() => { - if (activeTab && typeof document !== 'undefined') { - document.title = getDisplayName(activeTab); + if (Option.isSome(activeTabOption) && typeof document !== 'undefined') { + document.title = App.getTitle(activeTabOption.value.app); } }); // Update tab state function updateTabState(updatedTab: TabState) { - const tabId = getTabId(updatedTab); - tabsState.tabs = tabsState.tabs.map(tab => getTabId(tab) === tabId ? updatedTab : tab); - - // Update URL if this is the active tab - if (tabId === tabsState.activeTabId) { - const url = getTabUrl(updatedTab); - Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); - } + Effect.runPromise(TabsStateNS.updateTab(tabsState, updatedTab)) + .then(newState => { + tabsState = newState; + + // Update URL if this is the active tab + if (updatedTab.id === tabsState.activeTabId) { + const url = App.getUrl(updatedTab.app); + Effect.runPromise(NavigationServiceLive.navigateToUrl(url)) + .catch(error => console.error("Failed to navigate:", error)); + } + }) + .catch(error => console.error("Failed to update tab:", error)); } // Add new tab - async function addTab() { - try { - const tabId = `tab${tabsState.nextTabId}`; - const newTab = await Effect.runPromise(createTab({ app: "ChooseApp", id: tabId })); - tabsState.tabs = [...tabsState.tabs, newTab]; - tabsState.activeTabId = tabId; - tabsState.nextTabId++; - } catch (error) { - console.error("Failed to add tab:", error); - } + function addTab() { + Effect.runPromise(TabsStateNS.addTab(tabsState, ChooseApp())) + .then(newState => { + tabsState = newState; + }) + .catch(error => console.error("Failed to add tab:", error)); } // Remove tab - async function removeTab(tabId: string) { - if (tabsState.tabs.length === 1) return; - - // Clean up background tasks for the removed tab (no longer needed with simplified approach) - - tabsState.tabs = tabsState.tabs.filter(tab => getTabId(tab) !== tabId); - - if (tabsState.activeTabId === tabId) { - const firstTab = tabsState.tabs[0]; - if (firstTab) { - tabsState.activeTabId = getTabId(firstTab); - } - } + function removeTab(tabId: string) { + Effect.runPromise(TabsStateNS.removeTab(tabsState, tabId)) + .then(newState => { + tabsState = newState; + }) + .catch(error => console.error("Failed to remove tab:", error)); } // Set active tab function setActiveTab(tabId: string) { - tabsState.activeTabId = tabId; - - // Update URL for any tab type using the mapping function - const tab = tabsState.tabs.find(tab => getTabId(tab) === tabId); - if (tab) { - const url = getTabUrl(tab); - Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); - } + Effect.runPromise(TabsStateNS.setActiveTab(tabsState, tabId)) + .then(newState => { + tabsState = newState; + + // Update URL + const activeTabOption = TabsStateNS.getActiveTab(newState); + if (Option.isSome(activeTabOption)) { + const url = App.getUrl(activeTabOption.value.app); + Effect.runPromise(NavigationServiceLive.navigateToUrl(url)) + .catch(error => console.error("Failed to navigate:", error)); + } + }) + .catch(error => console.error("Failed to set active tab:", error)); } // Tab navigation functions function goToNextTab() { - const currentIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); - if (currentIndex !== -1 && currentIndex < tabsState.tabs.length - 1) { - setActiveTab(getTabId(tabsState.tabs[currentIndex + 1])); - } else if (tabsState.tabs.length > 1) { - // Wrap to first tab - setActiveTab(getTabId(tabsState.tabs[0])); - } + Effect.runPromise(TabsStateNS.nextTab(tabsState)) + .then(newState => { + tabsState = newState; + const activeTabOption = TabsStateNS.getActiveTab(newState); + if (Option.isSome(activeTabOption)) { + const url = App.getUrl(activeTabOption.value.app); + Effect.runPromise(NavigationServiceLive.navigateToUrl(url)) + .catch(error => console.error("Failed to navigate:", error)); + } + }) + .catch(error => console.error("Failed to navigate to next tab:", error)); } function goToPreviousTab() { - const currentIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); - if (currentIndex > 0) { - setActiveTab(getTabId(tabsState.tabs[currentIndex - 1])); - } else if (tabsState.tabs.length > 1) { - // Wrap to last tab - setActiveTab(getTabId(tabsState.tabs[tabsState.tabs.length - 1])); - } + Effect.runPromise(TabsStateNS.previousTab(tabsState)) + .then(newState => { + tabsState = newState; + const activeTabOption = TabsStateNS.getActiveTab(newState); + if (Option.isSome(activeTabOption)) { + const url = App.getUrl(activeTabOption.value.app); + Effect.runPromise(NavigationServiceLive.navigateToUrl(url)) + .catch(error => console.error("Failed to navigate:", error)); + } + }) + .catch(error => console.error("Failed to navigate to previous tab:", error)); } // Handle keyboard shortcuts @@ -180,73 +174,80 @@ // Handle app choice in ChooseApp tabs async function handleAppChoice(appType: "bible" | "about" | "stopwatch") { - try { - const tabIndex = tabsState.tabs.findIndex(tab => getTabId(tab) === tabsState.activeTabId); - if (tabIndex === -1) return; - - let newTab: TabState; - if (appType === "bible") { - const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); - newTab = await Effect.runPromise( - createTab({ - app: "Bible", - id: tabsState.activeTabId, - book: BibleBook.John, - chapter: 1, - translation, - showCanonExplorer: canonState - }) - ); - } else if (appType === "about") { - newTab = await Effect.runPromise(createTab({ app: "About", id: tabsState.activeTabId })); - } else if (appType === "stopwatch") { - newTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: tabsState.activeTabId })); - } else { - throw new Error(`Unknown app type: ${appType}`); - } - - tabsState.tabs = tabsState.tabs.map((tab, index) => index === tabIndex ? newTab : tab); - - // Update URL using the mapping function - const url = getTabUrl(newTab); - Effect.runPromise(NavigationServiceLive.navigateToUrl(url)); - } catch (error) { - console.error("Failed to handle app choice:", error); + const tabIndex = tabsState.tabs.findIndex(tab => tab.id === tabsState.activeTabId); + if (tabIndex === -1) return; + + let newTab: TabState; + if (appType === "bible") { + const canonState = await Effect.runPromise(ResponsiveServiceLive.getInitialCanonState()); + newTab = await Effect.runPromise( + createTab({ + app: "Bible", + id: tabsState.activeTabId, + book: BibleBook.John, + chapter: 1, + translation, + showCanonExplorer: canonState + }) + ); + } else if (appType === "about") { + newTab = await Effect.runPromise(createTab({ app: "About", id: tabsState.activeTabId })); + } else if (appType === "stopwatch") { + newTab = await Effect.runPromise(createTab({ app: "Stopwatch", id: tabsState.activeTabId })); + } else { + console.error(`Unknown app type: ${appType}`); + return; } + + tabsState.tabs = tabsState.tabs.map((tab, index) => index === tabIndex ? newTab : tab); + + // Update URL using the mapping function + const url = App.getUrl(newTab.app); + Effect.runPromise(NavigationServiceLive.navigateToUrl(url)) + .catch(error => console.error("Failed to navigate:", error)); } // Handle browser navigation async function syncFromURL() { - try { - const currentPage = $page; - const params = currentPage.params; - if (params.book && params.chapter) { - const urlState = await Effect.runPromise( - NavigationServiceLive.parseURL(currentPage.url.pathname) - ); - - if (urlState && activeTab && activeTab.app._tag === "Bible") { - const currentBook = activeTab.app.bibleState.currentBook; - const currentChapter = activeTab.app.bibleState.currentChapter; - - if (currentBook !== urlState.book || currentChapter !== urlState.chapter) { - const updatedTab = await Effect.runPromise( - createTab({ - app: "Bible", - id: activeTab.id, - book: urlState.book, - chapter: urlState.chapter, - translation: activeTab.app.bibleState.translation, - showCanonExplorer: activeTab.app.bibleState.showCanonExplorer - }) - ); - updateTabState(updatedTab); - } - } - } - } catch (error) { - console.error("Failed to sync from URL:", error); - } + const currentPage = $page; + const params = currentPage.params; + + if (!params.book || !params.chapter) return; + + const urlStateOption = await Effect.runPromise( + NavigationServiceLive.parseURL(currentPage.url.pathname) + ).catch(error => { + console.error("Failed to parse URL:", error); + return Option.none(); + }); + + if (Option.isNone(urlStateOption)) return; + if (Option.isNone(activeTabOption)) return; + + const activeTab = activeTabOption.value; + if (activeTab.app._tag !== "Bible") return; + + const urlState = urlStateOption.value; + const currentBook = activeTab.app.bibleState.currentBook; + const currentChapter = activeTab.app.bibleState.currentChapter; + + if (currentBook === urlState.book && currentChapter === urlState.chapter) return; + + const updatedTab = await Effect.runPromise( + createTab({ + app: "Bible", + id: activeTab.id, + book: urlState.book, + chapter: urlState.chapter, + translation: activeTab.app.bibleState.translation, + showCanonExplorer: activeTab.app.bibleState.showCanonExplorer + }) + ).catch(error => { + console.error("Failed to create updated tab:", error); + return activeTab; // Return unchanged on error + }); + + updateTabState(updatedTab); } // Initialize on mount @@ -280,7 +281,6 @@ diff --git a/site/src/lib/components/bible/BibleReader.svelte b/site/src/lib/components/bible/BibleReader.svelte index 91cad90..885bb25 100644 --- a/site/src/lib/components/bible/BibleReader.svelte +++ b/site/src/lib/components/bible/BibleReader.svelte @@ -39,7 +39,7 @@ let shouldFocusSearch = $state(false); // Translation content state - let translationContent = $state(null); + let translationContent = $state>(Option.none()); // Update internal state when props change (tab switch) $effect(() => { @@ -52,11 +52,11 @@ if (translation) { Effect.runPromise(loadTranslationContent(translation)) .then((content) => { - translationContent = content; + translationContent = Option.some(content); }) .catch((error) => { console.error("Failed to load translation content:", error); - translationContent = null; + translationContent = Option.none(); }); } }); @@ -65,12 +65,13 @@ // Update chapter data when internal state or translation content changes $effect(() => { - if (internalBook && internalChapter && translationContent) { - Effect.runPromise(getChapterFromContent(translationContent, internalBook, internalChapter)) + if (internalBook && internalChapter && Option.isSome(translationContent)) { + Effect.runPromise(getChapterFromContent(translationContent.value, internalBook, internalChapter)) .then((chapterOption) => { currentChapterData = chapterOption; }) - .catch(() => { + .catch((error) => { + console.error("Failed to get chapter:", error); currentChapterData = Option.none(); }); } else { @@ -96,8 +97,8 @@ } function navigateToNextChapter() { - if (!translationContent) return; - const nextChapterOption = getNextChapterFromContent(translationContent, internalBook, internalChapter, protestantBookOrder); + if (Option.isNone(translationContent)) return; + const nextChapterOption = getNextChapterFromContent(translationContent.value, internalBook, internalChapter, protestantBookOrder); if (Option.isSome(nextChapterOption)) { const { book, chapter } = nextChapterOption.value; internalBook = book; @@ -107,8 +108,8 @@ } function navigateToPreviousChapter() { - if (!translationContent) return; - const previousChapterOption = getPreviousChapterFromContent(translationContent, internalBook, internalChapter, protestantBookOrder); + if (Option.isNone(translationContent)) return; + const previousChapterOption = getPreviousChapterFromContent(translationContent.value, internalBook, internalChapter, protestantBookOrder); if (Option.isSome(previousChapterOption)) { const { book, chapter } = previousChapterOption.value; internalBook = book; @@ -223,7 +224,7 @@