Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions site/src/app.css
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
@import "tailwindcss";

html, body {
overflow: hidden;
height: 100%;
width: 100%;
position: fixed;
overscroll-behavior: none;
}
288 changes: 198 additions & 90 deletions site/src/lib/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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";
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,
Expand All @@ -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
Expand All @@ -35,30 +35,43 @@ 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<typeof BibleStateSchema>;
export type StopwatchState = Schema.Schema.Type<typeof StopwatchStateSchema>;
export type AppContent = Schema.Schema.Type<typeof AppContentSchema>;
export type Tab = Schema.Schema.Type<typeof TabSchema>;
// 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> = T extends object
? { -readonly [P in keyof T]: DeepWritable<T[P]> }
: T;

// Type exports - Schema.Type generates readonly types, make them writable for $state
type BibleStateReadonly = Schema.Schema.Type<typeof BibleStateSchema>;
type StopwatchStateReadonly = Schema.Schema.Type<typeof StopwatchStateSchema>;
type AppReadonly = Schema.Schema.Type<typeof AppSchema>;
type TabStateReadonly = Schema.Schema.Type<typeof TabStateSchema>;
type TabsStateReadonly = Schema.Schema.Type<typeof TabsStateSchema>;

export type BibleState = DeepWritable<BibleStateReadonly>;
export type StopwatchState = DeepWritable<StopwatchStateReadonly>;
export type App = DeepWritable<AppReadonly>;
export type TabState = DeepWritable<TabStateReadonly>;
export type TabsState = DeepWritable<TabsStateReadonly>;

// Maintain backward compatibility with Data constructors
export const BibleState = Data.case<BibleState>();
export const StopwatchState = Data.case<StopwatchState>();

// AppContent constructors
export const { Bible, About, ChooseApp, Stopwatch, $match } = Data.taggedEnum<AppContent>()

// Tab ID management using Effect
export const getTabId = (tab: Tab): string => {
return tab.id;
};
// App constructors
export const { Bible, About, ChooseApp, Stopwatch, $match } = Data.taggedEnum<App>()

// Format time as MM:SS for tab title
const formatTimeForTitle = (milliseconds: number): string => {
Expand All @@ -68,77 +81,172 @@ 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 => {
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";
}
return "Stopwatch";
}
});
});
};
}

// TabsState namespace - operations on TabsState type
export namespace TabsState {
export const addTab = (state: TabsState, app: App): Effect.Effect<TabsState> => {
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<TabsState> => {
return Effect.sync(() => {
// Don't allow removing last tab
if (state.tabs.length === 1) {
return state;
}

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<TabsState> => {
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<TabsState> => {
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<TabsState> => {
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<TabsState> => {
return Effect.sync(() => {
return {
...state,
tabs: state.tabs.map(tab => tab.id === updatedTab.id ? updatedTab : tab)
};
});
};

export const getActiveTab = (state: TabsState): Option.Option<TabState> => {
const tab = state.tabs.find(t => t.id === state.activeTabId);
return tab ? Option.some(tab) : Option.none();
};
}


// 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<TabState> => {
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()
});
}
};

// Tab state transformations using Effect
export const transformTab = (tab: Tab, transform: (tab: Tab) => Tab): Effect.Effect<Tab> =>
Effect.succeed(transform(tab));

export const createBibleTab = (
id: string,
book: BibleBook,
chapter: number,
translation: Translation,
showCanonExplorer: boolean
): Effect.Effect<Tab> =>
Effect.succeed({
id,
app: Bible({
bibleState: BibleState({
currentBook: book,
currentChapter: chapter,
translation,
showCanonExplorer
})
})
});

export const createAboutTab = (id: string): Effect.Effect<Tab> =>
Effect.succeed({
id,
app: About()
});

export const createChooseTab = (id: string): Effect.Effect<Tab> =>
Effect.succeed({
id,
app: ChooseApp()
});

export const createStopwatchTab = (id: string): Effect.Effect<Tab> =>
Effect.succeed({
id,
app: Stopwatch({
stopwatchState: StopwatchState({
elapsedTime: 0,
isRunning: false
})
})
});


// 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"
});
};
Loading