diff --git a/.gitignore b/.gitignore index 2481ecb..28ad15b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,3 @@ erl_crash.dump /.lustre /dist -# Generated from SHEETS_DATA_BASE_URL and SHEETS_SOURCE_URL -/assets/runtime-config.js diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json deleted file mode 100644 index a36bd32..0000000 --- a/.wrangler/cache/wrangler-account.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "account": { - "id": "9f52ed810ad09a589c0045ad0e552873", - "name": "Renata.amutio@gmail.com's Account" - } -} \ No newline at end of file diff --git a/README.md b/README.md index 296c7ac..69f50e3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Sheets A browser-based D&D 2024 character builder written in Gleam and Lustre. It -loads rules data from a configurable 5e.tools-compatible source and keeps a -local, versioned character library in the browser. +loads rules data from a URL supplied by the user and keeps a local, versioned +character library in the browser. ## Features @@ -18,34 +18,30 @@ local, versioned character library in the browser. inspect the other connected characters; the Game Master gets a complete read-only sheet for each player -This is an unofficial project. Rules content remains attributed to its source. +Rules data is supplied at runtime and is not configured in the application build. ## Development Requirements: Gleam 1.17 or newer and Bun. -Configure the browser data endpoints, then start Lustre: +Start the Lustre development server: ```sh -export SHEETS_DATA_BASE_URL="<5e.tools-compatible data directory URL>" -export SHEETS_SOURCE_URL="" -bun dev/configure.mjs gleam run -m lustre/dev start ``` -Open . Run the same configuration generator before a -production build: +Open , select **Data** in the header, and enter the base +URL for a compatible rules data directory. The directory must contain +`races.json`, `backgrounds.json`, `feats.json`, `items-base.json`, `class/`, +`generated/`, and `spells/`. The URL is normalized with a trailing slash and +stored only in the current browser. + +Create a production build with: ```sh -bun dev/configure.mjs gleam run -m lustre/dev build ``` -The data base URL must contain the 5e.tools data tree used by the application, -including `races.json`, `backgrounds.json`, `feats.json`, `items-base.json`, -`class/`, and `spells/`. The URL is normalized with a trailing slash. When it -is unset or a request fails, the builder keeps its bundled core choices and -reports that it is using the offline catalog. ## Verification diff --git a/assets/styles.css b/assets/styles.css index e996594..1a6d7c0 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -12,6 +12,8 @@ --accent-soft: rgba(201, 255, 82, 0.1); --warning: #ffce67; --danger: #ff7c70; + --notification: #ff5d9e; + --notification-soft: rgba(255, 93, 158, 0.12); --font-display: "Manrope", sans-serif; --font-body: "DM Sans", sans-serif; --font-mono: "DM Mono", monospace; @@ -79,23 +81,63 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .data-status.loading .status-light { animation: pulse 1.2s infinite; } .data-status.offline .status-light { background: var(--warning); box-shadow: none; } .data-status strong, .data-status span { display: block; } +button.data-status { + padding: 4px 6px; + border: 0; + color: var(--ink); + background: transparent; + text-align: left; +} +button.data-status:hover { background: var(--surface-soft); } +.data-status-mobile-label { display: none !important; color: var(--muted); font: 600 9px var(--font-mono); text-transform: uppercase; } .data-status strong { font-size: 11px; font-weight: 600; } .data-status div span { margin-top: 2px; color: var(--faint); font: 9px var(--font-mono); } @keyframes pulse { 50% { opacity: .25; } } .topbar-actions { position: relative; justify-self: end; display: flex; align-items: center; gap: 10px; } -.characters-button, .print-button, .completeness-button { +.characters-button, .print-button { min-height: 34px; padding: 0 11px; border: 1px solid var(--line); color: var(--ink); background: var(--surface); font: 500 9px var(--font-mono); } .characters-button { display: flex; align-items: center; gap: 8px; } .characters-button strong { min-width: 18px; padding: 2px 5px; color: #111; background: var(--accent); border-radius: 10px; text-align: center; } -.completeness-button { border: 0; color: #b6bbb7; background: transparent; } .character-library { width: min(420px, calc(100vw - 24px)); max-height: calc(100vh - 100px); position: absolute; z-index: 40; top: 53px; right: 0; overflow: auto; padding: 18px; border: 1px solid var(--line-strong); background: #121513; box-shadow: 0 24px 70px rgba(0,0,0,.55); } +.rules-data-settings { + width: min(460px, calc(100vw - 24px)); + position: absolute; + z-index: 45; + top: 53px; + right: 0; + padding: 20px; + border: 1px solid var(--line-strong); + background: #121513; + box-shadow: 0 24px 70px rgba(0,0,0,.55); +} +.rules-data-settings-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.rules-data-settings-heading h2 { margin: 4px 0 0; font: 700 22px var(--font-display); } +.rules-data-settings-heading button { + border: 0; + color: var(--muted); + background: transparent; + font-size: 22px; +} +.rules-data-settings > p { margin: 14px 0; color: var(--muted); font-size: 11px; line-height: 1.55; } +.rules-data-url-field { display: grid; gap: 7px; } +.rules-data-url-field > span { color: var(--muted); font: 500 9px var(--font-mono); letter-spacing: .08em; } +.rules-data-url-field .text-input { font: 11px var(--font-mono); } +.rules-data-settings .rules-data-error { margin: 8px 0 0; color: var(--danger); } +.rules-data-actions { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.rules-data-actions button { min-height: 40px; } +.rules-data-settings > small { display: block; margin-top: 10px; color: var(--faint); font: 8px var(--font-mono); } .library-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; } .library-heading h2 { margin: 4px 0 0; font: 700 20px var(--font-display); } .library-heading button { border: 0; color: var(--muted); background: transparent; font-size: 22px; } @@ -184,6 +226,10 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible background: var(--accent); box-shadow: 0 12px 36px rgba(0,0,0,.48); } +.game-master-shell .global-dice-button { + right: auto; + left: 24px; +} .global-dice-button:hover { transform: translateY(-2px); box-shadow: 0 16px 42px rgba(0,0,0,.58); @@ -283,6 +329,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible justify-items: end; pointer-events: none; } +.game-master-shell .dice-window-layer { justify-items: start; } .dice-window { width: min(520px, 100%); max-height: calc(100vh - 48px); @@ -592,6 +639,33 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .choice-select { height: 39px; padding: 0; border: 0; border-bottom: 1px solid var(--line-strong); background: transparent; font: 700 15px var(--font-display); } .choice-select:focus { background: transparent; } .choice-description { margin-top: 9px; color: var(--muted); font-size: 10px; } +.selector-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.incomplete-selector > .text-input, +.incomplete-selector > .select-input { + border-color: var(--notification); + box-shadow: inset 0 -2px 0 var(--notification-soft); +} +.choice-card.incomplete-selector { + border-color: rgba(255, 93, 158, .65); + box-shadow: inset 3px 0 0 var(--notification); +} +.incomplete-callout { + margin: -6px 0 14px; + padding: 9px 11px; + display: flex; + align-items: center; + gap: 8px; + color: var(--notification); + border: 1px solid rgba(255, 93, 158, .45); + background: var(--notification-soft); + font: 600 10px var(--font-mono); +} + .progression-tabs { margin: -8px 0 20px; @@ -601,6 +675,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .progression-tab { padding: 11px 15px; + display: inline-flex; + align-items: center; + gap: 7px; border: 0; border-bottom: 2px solid transparent; color: var(--muted); @@ -611,6 +688,21 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible } .progression-tab:hover { color: var(--ink); } .progression-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.incomplete-indicator { + min-width: 16px; + height: 16px; + padding: 0 4px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 999px; + color: #190811; + background: var(--notification); + box-shadow: 0 0 10px rgba(255, 93, 158, .55); + font: 800 9px/1 var(--font-mono); + letter-spacing: 0; +} .progression-tabs { overflow-x: auto; scrollbar-width: thin; } .progression-tab { flex: 0 0 auto; white-space: nowrap; } .tab-description { margin: 0 0 14px; color: var(--muted); font-size: 10px; } @@ -1034,7 +1126,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .feature-heading span, .equipment-row small, .spell-row small, .stat-block > span, .stat-block small, .sheet-section-title span, .quick-stats span, .source-card p, footer { font-size: 10px; } -.characters-button, .print-button, .completeness-button, +.characters-button, .print-button, .character-actions button, .library-primary-actions button, .library-export-actions button, .peer-note, .connection-error, .empty-roster, .feature-count, .ability-name, .skill-ability, @@ -1079,13 +1171,18 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .skills-grid { grid-template-columns: repeat(2, 1fr); } .topbar-center { display: none; } .topbar { grid-template-columns: 1fr 1fr; } - .data-status { display: none; } + .data-status div { display: none; } + .data-status-mobile-label { display: block !important; } } @media (max-width: 600px) { .global-dice-button { right: 12px; bottom: 12px; } + .game-master-shell .global-dice-button { + right: auto; + left: 12px; + } .dice-window-layer { padding: 12px; } .dice-window { max-height: calc(100vh - 24px); diff --git a/dev/configure.mjs b/dev/configure.mjs deleted file mode 100644 index a12fa26..0000000 --- a/dev/configure.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; - -const dataBaseUrl = process.env.SHEETS_DATA_BASE_URL ?? ""; -const sourceUrl = process.env.SHEETS_SOURCE_URL ?? ""; - -await mkdir(new URL("../assets/", import.meta.url), { recursive: true }); -await writeFile( - new URL("../assets/runtime-config.js", import.meta.url), - `globalThis.SHEETS_CONFIG = ${JSON.stringify({ dataBaseUrl, sourceUrl })};\n`, -); diff --git a/gleam.toml b/gleam.toml index 2e53b89..689e6ef 100644 --- a/gleam.toml +++ b/gleam.toml @@ -36,7 +36,7 @@ port = 1234 title = "Sheets — D&D 2024 Character Builder" meta = [ { name = "theme-color", content = "#111413" }, - { name = "description", content = "An unofficial D&D 2024 character builder powered by 5e.tools data." }, + { name = "description", content = "An unofficial D&D 2024 character builder with a user-configured rules data source." }, ] links = [ { rel = "preconnect", href = "https://fonts.googleapis.com" }, @@ -46,6 +46,3 @@ stylesheets = [ { href = "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&display=swap" }, { href = "/styles.css" }, ] -scripts = [ - { src = "/runtime-config.js" }, -] diff --git a/src/sheets.gleam b/src/sheets.gleam index 4f54d07..144e1b9 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -66,6 +66,32 @@ pub type AbilitiesTab { LevelUpTab } +pub type CoreTab { + CoreAbilityScores + CoreSkillProficiencies +} + +pub type ProgressionTab { + ProgressionClassFeatures + ProgressionOrigins + ProgressionFeats + ProgressionLevelUp +} + +pub type ResourcesTab { + ResourcesEquipment + ResourcesSpells + ResourcesNotes +} + +pub type SectionTabs { + SectionTabs( + core: CoreTab, + progression: ProgressionTab, + resources: ResourcesTab, + ) +} + pub type SpellLevelFilter { AllSpellLevels SpellLevel(Int) @@ -240,13 +266,19 @@ pub type Model { spell_level_filter: SpellLevelFilter, spell_preparation_filter: SpellPreparationFilter, spell_order: SpellOrder, - active_abilities_tab: AbilitiesTab, + active_core_tab: CoreTab, + active_progression_tab: ProgressionTab, + active_resources_tab: ResourcesTab, active_page: PageTab, species: List(Choice), backgrounds: List(Choice), species_loaded: Bool, backgrounds_loaded: Bool, catalog_error: Bool, + rules_data_url: String, + rules_data_url_input: String, + rules_data_url_error: String, + rules_data_settings_open: Bool, peer_name: String, join_code: String, connection: Connection, @@ -268,8 +300,14 @@ const player_storage_key = "sheets.player-character" const library_storage_key = "sheets.character-library" +const rules_data_url_storage_key = "sheets.rules-data-url" + pub type Message { UserToggledCharacterLibrary + UserToggledRulesDataSettings + UserChangedRulesDataUrl(String) + UserSavedRulesDataUrl + RulesDataUrlLoaded(String) UserSelectedCharacter(String) UserCreatedCharacter UserDuplicatedCharacter(String) @@ -290,7 +328,6 @@ pub type Message { UserChangedLevel(String) UserChangedClass(String) UserChangedSubclass(String) - UserClickedCompleteness UserChangedFeatureChoice(String, String) UserSelectedAbilitiesTab(AbilitiesTab) UserSelectedPage(PageTab) @@ -395,7 +432,12 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { |> random.new_seed let character_id = browser.new_id() let character_created_at = browser.now_iso() - let #(active_page, active_abilities_tab) = initial_navigation() + let #(active_page, initial_character_tab) = initial_navigation() + let SectionTabs( + core: active_core_tab, + progression: active_progression_tab, + resources: active_resources_tab, + ) = initial_section_tabs(initial_character_tab) #( Model( character_id:, @@ -454,7 +496,9 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { spell_preparation_filter: AllSpellPreparation, spell_order: SpellLevelOrder, import_status: "", - active_abilities_tab:, + active_core_tab:, + active_progression_tab:, + active_resources_tab:, active_page:, species: data.fallback_species(), health_configuration_open: False, @@ -462,6 +506,10 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { species_loaded: False, backgrounds_loaded: False, catalog_error: False, + rules_data_url: "", + rules_data_url_input: "", + rules_data_url_error: "", + rules_data_settings_open: False, peer_name: "", join_code: "", connection: Disconnected, @@ -479,15 +527,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { ), effect.batch([ load_player_character(), - data.species(DataReturnedSpecies), - data.backgrounds(DataReturnedBackgrounds), - data.class_progression("Fighter", fn(result) { - DataReturnedClassProgression("Fighter", result) - }), - load_origins_for_character("Human", "Soldier"), - data.feats(DataReturnedFeats), - data.equipment(DataReturnedEquipment), - load_spells_for_character("Fighter", ""), + load_rules_data_url(), modem.init(UrlChanged), ]), ) @@ -547,11 +587,57 @@ fn abilities_tab_from_slug(slug: String) -> AbilitiesTab { } } +fn initial_section_tabs(tab: AbilitiesTab) -> SectionTabs { + let tabs = + SectionTabs( + core: CoreAbilityScores, + progression: ProgressionClassFeatures, + resources: ResourcesEquipment, + ) + + case tab { + AbilityScoresTab -> tabs + SkillProficienciesTab -> SectionTabs(..tabs, core: CoreSkillProficiencies) + ClassFeaturesTab -> tabs + OriginsTab -> SectionTabs(..tabs, progression: ProgressionOrigins) + FeatsTab -> SectionTabs(..tabs, progression: ProgressionFeats) + LevelUpTab -> SectionTabs(..tabs, progression: ProgressionLevelUp) + EquipmentTab -> tabs + SpellsTab -> SectionTabs(..tabs, resources: ResourcesSpells) + NotesTab -> SectionTabs(..tabs, resources: ResourcesNotes) + } +} + +fn select_character_tab(model: Model, tab: AbilitiesTab) -> Model { + case tab { + AbilityScoresTab -> Model(..model, active_core_tab: CoreAbilityScores) + SkillProficienciesTab -> + Model(..model, active_core_tab: CoreSkillProficiencies) + ClassFeaturesTab -> + Model(..model, active_progression_tab: ProgressionClassFeatures) + OriginsTab -> Model(..model, active_progression_tab: ProgressionOrigins) + FeatsTab -> Model(..model, active_progression_tab: ProgressionFeats) + LevelUpTab -> Model(..model, active_progression_tab: ProgressionLevelUp) + EquipmentTab -> Model(..model, active_resources_tab: ResourcesEquipment) + SpellsTab -> Model(..model, active_resources_tab: ResourcesSpells) + NotesTab -> Model(..model, active_resources_tab: ResourcesNotes) + } +} + +fn core_tab_to_character_tab(tab: CoreTab) -> AbilitiesTab { + case tab { + CoreAbilityScores -> AbilityScoresTab + CoreSkillProficiencies -> SkillProficienciesTab + } +} + fn load_origins_for_character( + rules_data_url: String, species_name: String, background_name: String, ) -> Effect(Message) { data.origin_species( + rules_data_url, species_name, background_name, fn(returned_background_name, result) { @@ -560,6 +646,105 @@ fn load_origins_for_character( ) } +fn normalize_rules_data_url(input: String) -> Result(String, String) { + let trimmed = string.trim(input) + case trimmed { + "" -> Ok("") + url -> + case + string.starts_with(url, "https://") + || string.starts_with(url, "http://") + { + False -> Error("Enter an http:// or https:// URL") + True -> + case string.ends_with(url, "/") { + True -> Ok(url) + False -> Ok(url <> "/") + } + } + } +} + +fn apply_rules_data_url( + model: Model, + rules_data_url: String, +) -> #(Model, Effect(Message)) { + let class_progression = case rules_data_url, model.class_name { + "", "" -> Error("Choose a class") + "", _ -> Error("Configure rules data to load class features") + _, "" -> Error("Choose a class") + _, _ -> Error("Loading class features") + } + let origin_details = case + rules_data_url, + model.species_name, + model.background_name + { + "", _, _ -> Error("Configure rules data to load origin details") + _, "", _ -> Error("Choose a species and background") + _, _, "" -> Error("Choose a species and background") + _, _, _ -> Error("Loading origin details") + } + let updated = + Model( + ..model, + rules_data_url:, + rules_data_url_input: rules_data_url, + rules_data_url_error: "", + rules_data_settings_open: False, + species: data.fallback_species(), + backgrounds: data.fallback_backgrounds(), + feat_catalog: [], + spell_catalog: [], + equipment_catalog: [], + species_loaded: False, + backgrounds_loaded: False, + catalog_error: False, + class_progression:, + progression_loading: rules_data_url != "" && model.class_name != "", + origin_details:, + ) + case rules_data_url { + "" -> #(updated, effect.none()) + rules_data_url -> #(updated, load_rules_catalog(updated, rules_data_url)) + } +} + +fn load_rules_catalog(model: Model, rules_data_url: String) -> Effect(Message) { + effect.batch([ + data.species(rules_data_url, DataReturnedSpecies), + data.backgrounds(rules_data_url, DataReturnedBackgrounds), + data.feats(rules_data_url, DataReturnedFeats), + data.equipment(rules_data_url, DataReturnedEquipment), + case model.class_name { + "" -> effect.none() + class_name -> + data.class_progression(rules_data_url, class_name, fn(result) { + DataReturnedClassProgression(class_name, result) + }) + }, + case model.species_name, model.background_name { + "", _ -> effect.none() + _, "" -> effect.none() + species_name, background_name -> + load_origins_for_character( + rules_data_url, + species_name, + background_name, + ) + }, + case model.class_name { + "" -> effect.none() + class_name -> + load_spells_for_character( + rules_data_url, + class_name, + model.subclass_name, + ) + }, + ]) +} + fn new_stored_character() -> StoredCharacter { let now = browser.now_iso() StoredCharacter( @@ -881,8 +1066,43 @@ fn upsert_feat( fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { case message { + RulesDataUrlLoaded(rules_data_url) -> + apply_rules_data_url(model, rules_data_url) + UserToggledRulesDataSettings -> #( + Model( + ..model, + rules_data_settings_open: !model.rules_data_settings_open, + rules_data_url_input: model.rules_data_url, + rules_data_url_error: "", + library_open: False, + ), + effect.none(), + ) + UserChangedRulesDataUrl(value) -> #( + Model(..model, rules_data_url_input: value, rules_data_url_error: ""), + effect.none(), + ) + UserSavedRulesDataUrl -> + case normalize_rules_data_url(model.rules_data_url_input) { + Error(reason) -> #( + Model(..model, rules_data_url_error: reason), + effect.none(), + ) + Ok(rules_data_url) -> { + let #(updated, loading) = apply_rules_data_url(model, rules_data_url) + #( + updated, + effect.batch([persist_rules_data_url(rules_data_url), loading]), + ) + } + } UserToggledCharacterLibrary -> #( - Model(..model, library_open: !model.library_open, import_status: ""), + Model( + ..model, + library_open: !model.library_open, + import_status: "", + rules_data_settings_open: False, + ), effect.none(), ) UserSelectedCharacter(id) -> @@ -982,14 +1202,6 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { effect.none(), ) UserClickedPrint -> #(model, browser.print_page()) - UserClickedCompleteness -> #( - Model( - ..model, - active_abilities_tab: incomplete_tab(model), - library_open: False, - ), - effect.none(), - ) UserChangedName(name) -> update_and_sync(model, Model(..model, character_name: name)) UserClickedPortrait -> #( @@ -1037,10 +1249,10 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { updated, effect.batch([ sync_effect, - data.class_progression(name, fn(result) { + data.class_progression(model.rules_data_url, name, fn(result) { DataReturnedClassProgression(name, result) }), - load_spells_for_character(name, ""), + load_spells_for_character(model.rules_data_url, name, ""), ]), ) } @@ -1051,7 +1263,11 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { updated, effect.batch([ persistence, - load_spells_for_character(model.class_name, name), + load_spells_for_character( + model.rules_data_url, + model.class_name, + name, + ), ]), ) } @@ -1103,16 +1319,16 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) } UserSelectedAbilitiesTab(tab) -> #( - Model(..model, active_abilities_tab: tab, active_page: CharacterPage), + select_character_tab(Model(..model, active_page: CharacterPage), tab), push_navigation(CharacterPage, tab), ) UserSelectedPage(page) -> #( Model(..model, active_page: page), - push_navigation(page, model.active_abilities_tab), + push_navigation(page, core_tab_to_character_tab(model.active_core_tab)), ) UrlChanged(location) -> { - let #(active_page, active_abilities_tab) = navigation_from_uri(location) - #(Model(..model, active_page:, active_abilities_tab:), effect.none()) + let #(active_page, tab) = navigation_from_uri(location) + #(select_character_tab(Model(..model, active_page:), tab), effect.none()) } UserOpenedDiceRoll(label, modifier) -> #( Model( @@ -1217,7 +1433,11 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { updated, effect.batch([ persistence, - load_origins_for_character(name, model.background_name), + load_origins_for_character( + model.rules_data_url, + name, + model.background_name, + ), ]), ) } @@ -1240,7 +1460,11 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { updated, effect.batch([ persistence, - load_origins_for_character(model.species_name, name), + load_origins_for_character( + model.rules_data_url, + model.species_name, + name, + ), ]), ) } @@ -1526,18 +1750,23 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Error(_) -> #( Model( ..model, - origin_details: Error("Could not load 5e.tools origin data"), + origin_details: Error("Could not load origin data"), ), effect.none(), ) Ok(species) -> #( model, - data.origin_background(species, background_name, fn(result) { - DataReturnedOrigins(case result { - Ok(details) -> Ok(details) - Error(_) -> Error("Could not load 5e.tools origin data") - }) - }), + data.origin_background( + model.rules_data_url, + species, + background_name, + fn(result) { + DataReturnedOrigins(case result { + Ok(details) -> Ok(details) + Error(_) -> Error("Could not load origin data") + }) + }, + ), ) } } @@ -1589,7 +1818,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Error(_) -> #(Model(..model, spell_catalog: []), effect.none()) Ok(identities) -> #( Model(..model, spell_catalog: []), - data.spell_index(fn(result) { + data.spell_index(model.rules_data_url, fn(result) { DataReturnedSpellIndex(spell_class, identities, result) }), ) @@ -1608,9 +1837,12 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model(..model, spell_catalog: []), files |> list.map(fn(filename) { - data.spell_file(filename, identities, fn(result) { - DataReturnedSpellFile(spell_class, result) - }) + data.spell_file( + model.rules_data_url, + filename, + identities, + fn(result) { DataReturnedSpellFile(spell_class, result) }, + ) }) |> effect.batch, ) @@ -1938,19 +2170,27 @@ fn load_stored_character( case character.class_name { "" -> effect.none() class_name -> - data.class_progression(class_name, fn(result) { + data.class_progression(model.rules_data_url, class_name, fn(result) { DataReturnedClassProgression(class_name, result) }) }, case character.species_name, character.background_name { "", "" -> effect.none() species_name, background_name -> - load_origins_for_character(species_name, background_name) + load_origins_for_character( + model.rules_data_url, + species_name, + background_name, + ) }, case character.class_name { "" -> effect.none() class_name -> - load_spells_for_character(class_name, character.subclass_name) + load_spells_for_character( + model.rules_data_url, + class_name, + character.subclass_name, + ) }, ]), ) @@ -1972,6 +2212,39 @@ fn player_storage() -> Result(varasto.TypedStorage(StoredCharacter), Nil) { } } +fn rules_data_url_storage() -> Result(varasto.TypedStorage(String), Nil) { + case varasto.local() { + Ok(storage) -> Ok(varasto.new(storage, decode.string, json.string)) + Error(Nil) -> Error(Nil) + } +} + +fn load_rules_data_url() -> Effect(Message) { + effect.from(fn(dispatch) { + case rules_data_url_storage() { + Error(Nil) -> dispatch(RulesDataUrlLoaded("")) + Ok(storage) -> + case varasto.get(storage, rules_data_url_storage_key) { + Ok(rules_data_url) -> dispatch(RulesDataUrlLoaded(rules_data_url)) + Error(varasto.NotFound) -> dispatch(RulesDataUrlLoaded("")) + Error(varasto.DecodeError(err: _)) -> dispatch(RulesDataUrlLoaded("")) + } + } + }) +} + +fn persist_rules_data_url(rules_data_url: String) -> Effect(Message) { + effect.from(fn(_dispatch) { + case rules_data_url_storage() { + Error(Nil) -> Nil + Ok(storage) -> { + let _ = varasto.set(storage, rules_data_url_storage_key, rules_data_url) + Nil + } + } + }) +} + fn load_player_character() -> Effect(Message) { effect.from(fn(dispatch) { case library_storage() { @@ -2574,8 +2847,13 @@ fn view(model: Model) -> Element(Message) { True, True -> view_player_page(model) } } + let shell_class = case model.connection { + Connected(role: GameMaster, ..) -> "app-shell game-master-shell" + Connecting(role: GameMaster) -> "app-shell game-master-shell" + _ -> "app-shell" + } - html.div([attribute.class("app-shell")], [ + html.div([attribute.class(shell_class)], [ view_header(model), case character_ready, model.connection { True, Connected(role: GameMaster, ..) -> html.text("") @@ -2742,7 +3020,9 @@ fn view_player_workspace(model: Model) -> Element(Message) { html.main([attribute.class("workspace")], [ html.div([attribute.class("builder")], [ view_identity(model), - view_abilities(model), + view_core_abilities(model), + view_character_progression(model), + view_character_resources(model), ]), view_sheet(model), ]) @@ -2778,127 +3058,72 @@ fn view_all_companion_sheets(model: Model) -> Element(Message) { } } -fn completeness_issues(model: Model) -> List(String) { - let issues = case string.trim(model.character_name) { - "" -> ["Name your character"] - _ -> [] - } - let class_issues = case model.class_progression { - Error(_) -> [] +fn class_choice_incomplete_count(model: Model) -> Int { + case model.class_progression { + Error(_) -> 0 Ok(progression) -> progression.choices |> list.filter(fn(choice) { visible_feature_choice(choice, model) && feature_choice_value(choice, model) == "" }) - |> list.map(fn(choice) { "Choose " <> choice.label }) + |> list.length } - let origin_issues = case model.origin_details { - Error(_) -> [] +} + +fn origin_choice_incomplete_count(model: Model) -> Int { + case model.origin_details { + Error(_) -> 0 Ok(details) -> details.choices |> list.filter(fn(choice) { feature_selection_value(model.feature_selections, choice.id) == "" }) - |> list.map(fn(choice) { "Choose " <> choice.label }) - } - let feat_issues = - feat_levels(model.class_name, model.level) - |> list.filter(fn(level) { - let feat = selected_feat(model.feats, level) - feat.name == "" - || { feat.name == "Ability Score Improvement" && feat.ability == "" } - }) - |> list.map(fn(level) { "Choose level " <> int.to_string(level) <> " feat" }) - let spell_issues = case - maximum_spell_level(model.class_name, model.subclass_name, model.level) - { - 0 -> [] - _ -> - case model.spells { - [] -> ["Prepare at least one spell"] - _ -> [] - } + |> list.length } - let health_issues = case pending_health_level(model) { - Ok(level) -> ["Choose level " <> int.to_string(level) <> " health"] - Error(Nil) -> [] - } - issues - |> list.append(class_issues) - |> list.append(origin_issues) - |> list.append(feat_issues) - |> list.append(spell_issues) - |> list.append(health_issues) } -fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { - case choice.id { - "subclass" -> model.subclass_name - id -> feature_selection_value(model.feature_selections, id) - } +fn feat_choice_incomplete_count(model: Model) -> Int { + feat_levels(model.class_name, model.level) + |> list.filter(fn(level) { + let feat = selected_feat(model.feats, level) + feat.name == "" + || { feat.name == "Ability Score Improvement" && feat.ability == "" } + }) + |> list.length } -fn completeness_label(model: Model) -> String { - case list.length(completeness_issues(model)) { - 0 -> "Character complete" - count -> int.to_string(count) <> " choices remaining" - } +fn spell_selection_incomplete(model: Model) -> Bool { + maximum_spell_level(model.class_name, model.subclass_name, model.level) > 0 + && list.is_empty(model.spells) } -fn incomplete_tab(model: Model) -> AbilitiesTab { - case completeness_issues(model) { - [] -> AbilityScoresTab - _ -> - case model.class_progression { - Ok(progression) -> - case - list.any(progression.choices, fn(choice) { - visible_feature_choice(choice, model) - && feature_choice_value(choice, model) == "" - }) - { - True -> ClassFeaturesTab - False -> incomplete_non_class_tab(model) - } - Error(_) -> incomplete_non_class_tab(model) +fn tab_incomplete_count(model: Model, tab: AbilitiesTab) -> Int { + case tab { + ClassFeaturesTab -> class_choice_incomplete_count(model) + OriginsTab -> origin_choice_incomplete_count(model) + FeatsTab -> feat_choice_incomplete_count(model) + SpellsTab -> + case spell_selection_incomplete(model) { + True -> 1 + False -> 0 } - } -} - -fn incomplete_non_class_tab(model: Model) -> AbilitiesTab { - case model.origin_details { - Ok(details) -> - case - list.any(details.choices, fn(choice) { - feature_selection_value(model.feature_selections, choice.id) == "" - }) - { - True -> OriginsTab - False -> incomplete_feat_or_spell_tab(model) + LevelUpTab -> + case pending_health_level(model) { + Ok(_) -> 1 + Error(Nil) -> 0 } - Error(_) -> incomplete_feat_or_spell_tab(model) + AbilityScoresTab -> 0 + SkillProficienciesTab -> 0 + EquipmentTab -> 0 + NotesTab -> 0 } } -fn incomplete_feat_or_spell_tab(model: Model) -> AbilitiesTab { - let missing_feat = - list.any(feat_levels(model.class_name, model.level), fn(level) { - let feat = selected_feat(model.feats, level) - feat.name == "" - || { feat.name == "Ability Score Improvement" && feat.ability == "" } - }) - case missing_feat { - True -> FeatsTab - False -> - case - maximum_spell_level(model.class_name, model.subclass_name, model.level) - > 0 - && list.is_empty(model.spells) - { - True -> SpellsTab - False -> AbilityScoresTab - } +fn feature_choice_value(choice: FeatureChoice, model: Model) -> String { + case choice.id { + "subclass" -> model.subclass_name + id -> feature_selection_value(model.feature_selections, id) } } @@ -2914,14 +3139,14 @@ fn character_count(model: Model) -> Int { } fn view_header(model: Model) -> Element(Message) { - let #(status_class, status_text) = case - model.species_loaded, - model.backgrounds_loaded, - model.catalog_error - { - True, True, False -> #("ready", "Live data synced") - True, True, True -> #("offline", "Using offline catalog") - _, _, _ -> #("loading", "Syncing 5e.tools data") + let #(status_class, status_text) = case model.rules_data_url { + "" -> #("offline", "Rules data not configured") + _ -> + case model.species_loaded, model.backgrounds_loaded, model.catalog_error { + True, True, False -> #("ready", "Rules data synced") + True, True, True -> #("offline", "Using offline catalog") + _, _, _ -> #("loading", "Syncing rules data") + } } html.header([attribute.class("topbar")], [ @@ -2934,21 +3159,6 @@ fn view_header(model: Model) -> Element(Message) { ]), html.div([attribute.class("topbar-center")], [ html.span([attribute.class("edition-pill")], [html.text("2024 RULESET")]), - case model.has_character { - False -> html.text("") - True -> - html.button( - [ - attribute.type_("button"), - attribute.class("completeness-button"), - event.on_click(UserClickedCompleteness), - ], - [ - html.span([attribute.class("save-dot")], []), - html.text(completeness_label(model)), - ], - ) - }, ]), html.div([attribute.class("topbar-actions")], [ html.button( @@ -2977,21 +3187,104 @@ fn view_header(model: Model) -> Element(Message) { [html.text("Print")], ) }, - html.div([attribute.class("data-status " <> status_class)], [ - html.span([attribute.class("status-light")], []), - html.div([], [ - html.strong([], [html.text(status_text)]), - html.span([], [html.text("5e.tools · 2024")]), - ]), - ]), + html.button( + [ + attribute.type_("button"), + attribute.class("data-status " <> status_class), + attribute.aria_expanded(model.rules_data_settings_open), + attribute.aria_label("Configure rules data URL"), + event.on_click(UserToggledRulesDataSettings), + ], + [ + html.span([attribute.class("status-light")], []), + html.div([], [ + html.strong([], [html.text(status_text)]), + html.span([], [html.text("Browser data source")]), + ]), + html.span([attribute.class("data-status-mobile-label")], [ + html.text("Data"), + ]), + ], + ), case model.library_open { True -> view_character_library(model) False -> html.text("") }, + case model.rules_data_settings_open { + True -> view_rules_data_settings(model) + False -> html.text("") + }, ]), ]) } +fn view_rules_data_settings(model: Model) -> Element(Message) { + html.div( + [ + attribute.class("rules-data-settings"), + attribute.role("dialog"), + attribute.aria_label("Rules data source"), + ], + [ + html.div([attribute.class("rules-data-settings-heading")], [ + html.div([], [ + html.span([attribute.class("eyebrow")], [html.text("DATA SOURCE")]), + html.h2([], [html.text("Rules data URL")]), + ]), + html.button( + [ + attribute.type_("button"), + attribute.aria_label("Close rules data settings"), + event.on_click(UserToggledRulesDataSettings), + ], + [html.text("×")], + ), + ]), + html.p([], [ + html.text( + "Enter the base URL of a compatible rules data directory. It must contain races.json, backgrounds.json, feats.json, items-base.json, class/, generated/, and spells/.", + ), + ]), + html.label([attribute.class("rules-data-url-field")], [ + html.span([], [html.text("DATA DIRECTORY URL")]), + html.input([ + attribute.type_("url"), + attribute.class("text-input"), + attribute.placeholder("https://example.com/data/"), + attribute.value(model.rules_data_url_input), + event.on_input(UserChangedRulesDataUrl), + ]), + ]), + case model.rules_data_url_error { + "" -> html.text("") + reason -> + html.p([attribute.class("rules-data-error")], [html.text(reason)]) + }, + html.div([attribute.class("rules-data-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("secondary-button"), + event.on_click(UserChangedRulesDataUrl("")), + ], + [html.text("Clear")], + ), + html.button( + [ + attribute.type_("button"), + attribute.class("primary-button"), + event.on_click(UserSavedRulesDataUrl), + ], + [html.text("Save & load")], + ), + ]), + html.small([], [ + html.text("The URL is stored only in this browser."), + ]), + ], + ) +} + fn view_delete_confirmation(model: Model) -> Element(Message) { let character_name = case list.find(model.characters, fn(character) { @@ -4213,8 +4506,9 @@ fn view_identity(model: Model) -> Element(Message) { html.section([attribute.class("panel identity-panel")], [ section_heading("01", "Character foundation", "Choose your core identity"), html.div([attribute.class("identity-grid")], [ - field( + required_field( "Character name", + string.trim(model.character_name) == "", html.input([ attribute.class("text-input name-input"), attribute.placeholder("Name your hero"), @@ -4264,77 +4558,124 @@ fn view_identity(model: Model) -> Element(Message) { ]) } -fn view_abilities(model: Model) -> Element(Message) { +fn view_core_abilities(model: Model) -> Element(Message) { html.section([attribute.class("panel progression-panel")], [ - section_heading( - "02", - "Abilities & progression", - "Scores and class features available at your current level", - ), + section_heading("02", "Abilities", "Ability scores and skill proficiencies"), html.div( [ attribute.class("progression-tabs"), attribute.role("tablist"), - attribute.aria_label("Ability and progression sections"), + attribute.aria_label("Abilities"), ], [ progression_tab( "Ability scores", - model.active_abilities_tab == AbilityScoresTab, + model.active_core_tab == CoreAbilityScores, AbilityScoresTab, + tab_incomplete_count(model, AbilityScoresTab), ), progression_tab( - "Class features", - model.active_abilities_tab == ClassFeaturesTab, - ClassFeaturesTab, + "Skills", + model.active_core_tab == CoreSkillProficiencies, + SkillProficienciesTab, + tab_incomplete_count(model, SkillProficienciesTab), ), + ], + ), + case model.active_core_tab { + CoreAbilityScores -> view_ability_scores(model) + CoreSkillProficiencies -> view_skill_proficiencies(model) + }, + ]) +} + +fn view_character_progression(model: Model) -> Element(Message) { + html.section([attribute.class("panel progression-panel")], [ + section_heading( + "03", + "Class progression", + "Class features, origins, feats, and level advancement", + ), + html.div( + [ + attribute.class("progression-tabs"), + attribute.role("tablist"), + attribute.aria_label("Class progression"), + ], + [ progression_tab( - "Skill proficiencies", - model.active_abilities_tab == SkillProficienciesTab, - SkillProficienciesTab, + "Class features", + model.active_progression_tab == ProgressionClassFeatures, + ClassFeaturesTab, + tab_incomplete_count(model, ClassFeaturesTab), ), progression_tab( "Origins", - model.active_abilities_tab == OriginsTab, + model.active_progression_tab == ProgressionOrigins, OriginsTab, - ), - progression_tab( - "Equipment", - model.active_abilities_tab == EquipmentTab, - EquipmentTab, + tab_incomplete_count(model, OriginsTab), ), progression_tab( "Feats", - model.active_abilities_tab == FeatsTab, + model.active_progression_tab == ProgressionFeats, FeatsTab, + tab_incomplete_count(model, FeatsTab), + ), + progression_tab( + "Level up", + model.active_progression_tab == ProgressionLevelUp, + LevelUpTab, + tab_incomplete_count(model, LevelUpTab), + ), + ], + ), + case model.active_progression_tab { + ProgressionClassFeatures -> view_class_features(model) + ProgressionOrigins -> view_origins(model) + ProgressionFeats -> view_feats(model) + ProgressionLevelUp -> view_level_up(model) + }, + ]) +} + +fn view_character_resources(model: Model) -> Element(Message) { + html.section([attribute.class("panel progression-panel")], [ + section_heading( + "04", + "Equipment & magic", + "Inventory, spell preparation, and campaign notes", + ), + html.div( + [ + attribute.class("progression-tabs"), + attribute.role("tablist"), + attribute.aria_label("Equipment, magic, and notes"), + ], + [ + progression_tab( + "Equipment", + model.active_resources_tab == ResourcesEquipment, + EquipmentTab, + tab_incomplete_count(model, EquipmentTab), ), progression_tab( "Spells", - model.active_abilities_tab == SpellsTab, + model.active_resources_tab == ResourcesSpells, SpellsTab, + tab_incomplete_count(model, SpellsTab), ), progression_tab( "Notes", - model.active_abilities_tab == NotesTab, + model.active_resources_tab == ResourcesNotes, NotesTab, - ), - progression_tab( - "Level up", - model.active_abilities_tab == LevelUpTab, - LevelUpTab, + tab_incomplete_count(model, NotesTab), ), ], ), - case model.active_abilities_tab { - AbilityScoresTab -> view_ability_scores(model) - ClassFeaturesTab -> view_class_features(model) - SkillProficienciesTab -> view_skill_proficiencies(model) - OriginsTab -> view_origins(model) - EquipmentTab -> view_equipment(model) - FeatsTab -> view_feats(model) - SpellsTab -> view_spells(model) - NotesTab -> view_notes(model) - LevelUpTab -> view_level_up(model) + case model.active_resources_tab { + ResourcesEquipment -> view_equipment(model) + ResourcesSpells -> view_spells(model) + ResourcesNotes -> view_notes(model) }, ]) } @@ -4343,16 +4684,53 @@ fn progression_tab( label: String, selected: Bool, tab: AbilitiesTab, + incomplete_count: Int, ) -> Element(Message) { html.button( [ attribute.type_("button"), attribute.role("tab"), - attribute.classes([#("progression-tab", True), #("active", selected)]), + attribute.classes([ + #("progression-tab", True), + #("active", selected), + #("incomplete", incomplete_count > 0), + ]), attribute.aria_selected(selected), event.on_click(UserSelectedAbilitiesTab(tab)), ], - [html.text(label)], + [ + html.text(label), + incomplete_indicator(incomplete_count, label), + ], + ) +} + +fn incomplete_indicator(count: Int, label: String) -> Element(Message) { + case count { + 0 -> html.text("") + count -> + html.span( + [ + attribute.class("incomplete-indicator"), + attribute.aria_label( + label <> " has " <> int.to_string(count) <> " incomplete choices", + ), + ], + [html.text(int.to_string(count))], + ) + } +} + +fn selector_incomplete_indicator( + incomplete: Bool, + label: String, +) -> Element(Message) { + incomplete_indicator( + case incomplete { + True -> 1 + False -> 0 + }, + label, ) } @@ -4415,7 +4793,7 @@ fn view_class_features(model: Model) -> Element(Message) { True, _ -> html.div([attribute.class("progression-state")], [ html.span([attribute.class("connection-spinner")], []), - html.p([], [html.text("Loading 5e.tools class progression…")]), + html.p([], [html.text("Loading class progression…")]), ]) False, Error(reason) -> html.div([attribute.class("progression-state error")], [ @@ -4606,34 +4984,43 @@ fn view_feature_choice( "subclass" -> UserChangedSubclass id -> fn(value) { UserChangedFeatureChoice(id, value) } } - html.label([attribute.class("feature-choice")], [ - html.span([], [ - html.text(choice.label <> " · LEVEL " <> int.to_string(choice.level)), - ]), - html.select([attribute.class("select-input"), event.on_change(changed)], [ - html.option( - [attribute.value(""), attribute.selected(selected == "")], - "Choose…", - ), - ..list.map(feature_choice_options(choice, model), fn(option) { + html.label( + [ + attribute.classes([ + #("feature-choice", True), + #("incomplete-selector", selected == ""), + ]), + ], + [ + html.span([attribute.class("selector-label")], [ + html.text(choice.label <> " · LEVEL " <> int.to_string(choice.level)), + selector_incomplete_indicator(selected == "", choice.label), + ]), + html.select([attribute.class("select-input"), event.on_change(changed)], [ html.option( - [ - attribute.value(option), - attribute.selected(option == selected), - attribute.disabled( - option != selected - && feature_choice_option_is_selected( - choice, - model.feature_selections, - option, + [attribute.value(""), attribute.selected(selected == "")], + "Choose…", + ), + ..list.map(feature_choice_options(choice, model), fn(option) { + html.option( + [ + attribute.value(option), + attribute.selected(option == selected), + attribute.disabled( + option != selected + && feature_choice_option_is_selected( + choice, + model.feature_selections, + option, + ), ), - ), - ], - option, - ) - }) - ]), - ]) + ], + option, + ) + }) + ]), + ], + ) } fn feature_selection_value( @@ -5340,77 +5727,106 @@ fn view_feats(model: Model) -> Element(Message) { html.strong([], [html.text(int.to_string(level))]), ]), html.div([], [ - html.label([], [ - html.span([], [html.text("Feat or improvement")]), - html.select( - [ - attribute.class("select-input"), - event.on_change(fn(name) { UserSelectedFeat(level, name) }), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(selected.name == ""), - ], - "Choose…", - ), - html.option( - [ - attribute.value("Ability Score Improvement"), - attribute.selected( - selected.name == "Ability Score Improvement", - ), - ], - "Ability Score Improvement", + html.label( + [ + attribute.classes([ + #("incomplete-selector", selected.name == ""), + ]), + ], + [ + html.span([attribute.class("selector-label")], [ + html.text("Feat or improvement"), + selector_incomplete_indicator( + selected.name == "", + "Feat or improvement", ), - ..list.map( - available_feats(model.feat_catalog, level), - fn(feat) { - html.option( - [ - attribute.value(feat.name), - attribute.selected(selected.name == feat.name), - ], - feat.name, - ) - }, - ) - ], - ), - ]), + ]), + html.select( + [ + attribute.class("select-input"), + event.on_change(fn(name) { + UserSelectedFeat(level, name) + }), + ], + [ + html.option( + [ + attribute.value(""), + attribute.selected(selected.name == ""), + ], + "Choose…", + ), + html.option( + [ + attribute.value("Ability Score Improvement"), + attribute.selected( + selected.name == "Ability Score Improvement", + ), + ], + "Ability Score Improvement", + ), + ..list.map( + available_feats(model.feat_catalog, level), + fn(feat) { + html.option( + [ + attribute.value(feat.name), + attribute.selected(selected.name == feat.name), + ], + feat.name, + ) + }, + ) + ], + ), + ], + ), case selected.name { "Ability Score Improvement" -> - html.label([attribute.class("feat-ability")], [ - html.span([], [html.text("Increase by 2")]), - html.select( - [ - attribute.class("select-input"), - event.on_change(fn(ability) { - UserSelectedFeatAbility(level, ability) - }), - ], - [ - html.option( - [ - attribute.value(""), - attribute.selected(selected.ability == ""), - ], - "Choose ability…", + html.label( + [ + attribute.classes([ + #("feat-ability", True), + #("incomplete-selector", selected.ability == ""), + ]), + ], + [ + html.span([attribute.class("selector-label")], [ + html.text("Increase by 2"), + selector_incomplete_indicator( + selected.ability == "", + "Ability score improvement", ), - ..list.map(ability_list(), fn(ability) { - let name = ability_name(ability) + ]), + html.select( + [ + attribute.class("select-input"), + event.on_change(fn(ability) { + UserSelectedFeatAbility(level, ability) + }), + ], + [ html.option( [ - attribute.value(name), - attribute.selected(selected.ability == name), + attribute.value(""), + attribute.selected(selected.ability == ""), ], - name, - ) - }) - ], - ), - ]) + "Choose ability…", + ), + ..list.map(ability_list(), fn(ability) { + let name = ability_name(ability) + html.option( + [ + attribute.value(name), + attribute.selected(selected.ability == name), + ], + name, + ) + }) + ], + ), + ], + ) _ -> view_selected_feat_description( model.feat_catalog, @@ -5500,6 +5916,14 @@ fn view_spells(model: Model) -> Element(Message) { spellcasting_ability_name(model.class_name), ), ]), + case spell_selection_incomplete(model) { + True -> + html.div([attribute.class("incomplete-callout")], [ + incomplete_indicator(1, "Spell selection"), + html.span([], [html.text("Choose at least one spell")]), + ]) + False -> html.text("") + }, view_spell_controls(model, maximum_level, list.length(available)), case maximum_level { 0 -> @@ -5919,20 +6343,11 @@ fn view_sheet(model: Model) -> Element(Message) { ]), ]), html.div([attribute.class("source-card")], [ - html.span([attribute.class("source-icon")], [html.text("5e")]), + html.span([attribute.class("source-icon")], [html.text("24")]), html.div([], [ html.strong([], [html.text("2024 rules data")]), - html.p([], [html.text("2024 species · XPHB backgrounds")]), + html.p([], [html.text("Species · backgrounds · classes · spells")]), ]), - html.a( - [ - attribute.href(data.source_url()), - attribute.target("_blank"), - attribute.rel("noreferrer"), - attribute.aria_label("Open 5e.tools data source"), - ], - [html.text("↗")], - ), ]), ]) } @@ -6054,6 +6469,28 @@ fn field(label: String, control: Element(Message)) -> Element(Message) { ]) } +fn required_field( + label: String, + incomplete: Bool, + control: Element(Message), +) -> Element(Message) { + html.label( + [ + attribute.classes([ + #("field", True), + #("incomplete-selector", incomplete), + ]), + ], + [ + html.span([attribute.class("selector-label")], [ + html.text(label), + selector_incomplete_indicator(incomplete, label), + ]), + control, + ], + ) +} + fn choice_select( label: String, description: String, @@ -6061,28 +6498,44 @@ fn choice_select( choices: List(String), changed: fn(String) -> Message, ) -> Element(Message) { - html.label([attribute.class("choice-card")], [ - html.span([attribute.class("choice-label")], [html.text(label)]), - html.select( - [attribute.class("select-input choice-select"), event.on_change(changed)], - choices - |> list.map(fn(choice) { - html.option( - [attribute.value(choice), attribute.selected(choice == selected)], - choice, - ) - }) - |> list.prepend(html.option( - [ - attribute.value(""), - attribute.selected(selected == ""), - attribute.disabled(True), - ], - "Select " <> string.lowercase(label) <> "…", - )), - ), - html.span([attribute.class("choice-description")], [html.text(description)]), - ]) + html.label( + [ + attribute.classes([ + #("choice-card", True), + #("incomplete-selector", selected == ""), + ]), + ], + [ + html.span([attribute.class("selector-label choice-label")], [ + html.text(label), + selector_incomplete_indicator(selected == "", label), + ]), + html.select( + [ + attribute.class("select-input choice-select"), + event.on_change(changed), + ], + choices + |> list.map(fn(choice) { + html.option( + [attribute.value(choice), attribute.selected(choice == selected)], + choice, + ) + }) + |> list.prepend(html.option( + [ + attribute.value(""), + attribute.selected(selected == ""), + attribute.disabled(True), + ], + "Select " <> string.lowercase(label) <> "…", + )), + ), + html.span([attribute.class("choice-description")], [ + html.text(description), + ]), + ], + ) } fn selected_feat(feats: List(FeatSelection), level: Int) -> FeatSelection { @@ -6168,11 +6621,12 @@ fn spell_catalog_class(class_name: String, subclass_name: String) -> String { } fn load_spells_for_character( + rules_data_url: String, class_name: String, subclass_name: String, ) -> Effect(Message) { let spell_class = spell_catalog_class(class_name, subclass_name) - data.spell_lookup(spell_class, fn(result) { + data.spell_lookup(rules_data_url, spell_class, fn(result) { DataReturnedSpellLookup(spell_class, result) }) } diff --git a/src/sheets/config.gleam b/src/sheets/config.gleam deleted file mode 100644 index 162fced..0000000 --- a/src/sheets/config.gleam +++ /dev/null @@ -1,13 +0,0 @@ -pub fn data_base_url() -> String { - runtime_data_base_url() -} - -pub fn source_url() -> String { - runtime_source_url() -} - -@external(javascript, "./runtime_config.mjs", "dataBaseUrl") -fn runtime_data_base_url() -> String - -@external(javascript, "./runtime_config.mjs", "sourceUrl") -fn runtime_source_url() -> String diff --git a/src/sheets/data.gleam b/src/sheets/data.gleam index b8c4768..9e6f7d3 100644 --- a/src/sheets/data.gleam +++ b/src/sheets/data.gleam @@ -7,11 +7,6 @@ import gleam/result import gleam/string import lustre/effect.{type Effect} import rsvp -import sheets/config - -pub fn source_url() -> String { - config.source_url() -} pub type Choice { Choice(name: String, source: String) @@ -241,11 +236,12 @@ pub opaque type OriginSpecies { } pub fn origin_species( + base_url: String, species_name: String, background_name: String, handler: fn(String, Result(OriginSpecies, rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -258,11 +254,12 @@ pub fn origin_species( } pub fn origin_background( + base_url: String, species: OriginSpecies, background_name: String, handler: fn(Result(OriginDetails, rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -276,9 +273,10 @@ pub fn origin_background( } pub fn equipment( + base_url: String, handler: fn(Result(List(InventoryItem), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -289,9 +287,10 @@ pub fn equipment( } pub fn feats( + base_url: String, handler: fn(Result(List(Feat), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -302,10 +301,11 @@ pub fn feats( } pub fn spell_lookup( + base_url: String, class_name: String, handler: fn(Result(List(SpellIdentity), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -316,9 +316,10 @@ pub fn spell_lookup( } pub fn spell_index( + base_url: String, handler: fn(Result(List(String), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -332,11 +333,12 @@ pub fn spell_index( } pub fn spell_file( + base_url: String, filename: String, identities: List(SpellIdentity), handler: fn(Result(List(Spell), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -347,13 +349,14 @@ pub fn spell_file( } pub fn class_progression( + base_url: String, class_name: String, handler: fn(Result(ClassProgression, String)) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.from(fn(dispatch) { - dispatch(handler(Error("5e.tools data URL is not configured"))) + dispatch(handler(Error("Rules data URL is not configured"))) }) base_url -> { let slug = @@ -365,7 +368,7 @@ pub fn class_progression( rsvp.expect_json(class_progression_decoder(class_name), fn(result) { handler(case result { Ok(progression) -> Ok(progression) - Error(_) -> Error("Could not decode 5e.tools class data") + Error(_) -> Error("Could not decode class data") }) }), ) @@ -374,9 +377,10 @@ pub fn class_progression( } pub fn species( + base_url: String, handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( @@ -387,9 +391,10 @@ pub fn species( } pub fn backgrounds( + base_url: String, handler: fn(Result(List(Choice), rsvp.Error(String))) -> message, ) -> Effect(message) { - case config.data_base_url() { + case base_url { "" -> effect.none() base_url -> rsvp.get( diff --git a/src/sheets/runtime_config.mjs b/src/sheets/runtime_config.mjs deleted file mode 100644 index d1b4a12..0000000 --- a/src/sheets/runtime_config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -function config() { - return globalThis.SHEETS_CONFIG ?? {}; -} - -export function dataBaseUrl() { - const base = config().dataBaseUrl ?? ""; - return base === "" ? "" : `${base.replace(/\/+$/, "")}/`; -} - -export function sourceUrl() { - return config().sourceUrl ?? ""; -}