diff --git a/assets/styles.css b/assets/styles.css index 16d634e..e328d85 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -917,10 +917,16 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .sheet { position: sticky; top: 106px; overflow: hidden; background: #1a1e1b; } .sheet-accent { height: 4px; background: var(--accent); } .portrait { position: relative; height: 180px; display: grid; place-items: center; background: linear-gradient(145deg, #252b26, #151815); overflow: hidden; } -.portrait::before, .portrait::after { content: ""; position: absolute; border: 1px solid #333a34; transform: rotate(45deg); } +.portrait::before, .portrait::after { content: ""; position: absolute; z-index: 0; border: 1px solid #333a34; transform: rotate(45deg); } .portrait::before { width: 150px; height: 150px; } .portrait::after { width: 110px; height: 110px; } -.portrait > span { z-index: 1; color: #3b433c; font: 800 86px var(--font-display); text-transform: uppercase; } +.portrait-initial { z-index: 1; color: #3b433c; font: 800 86px var(--font-display); text-transform: uppercase; } +.portrait-image { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.portrait-actions { position: absolute; z-index: 3; left: 10px; bottom: 10px; display: flex; gap: 6px; opacity: 0; transform: translateY(4px); transition: opacity .15s ease, transform .15s ease; } +.editable-portrait:hover .portrait-actions, .editable-portrait:focus-within .portrait-actions { opacity: 1; transform: translateY(0); } +.portrait-actions button { min-height: 30px; padding: 0 9px; border: 1px solid rgba(255,255,255,.38); color: #fff; background: rgba(10,12,10,.82); font: 700 8px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; cursor: pointer; } +.portrait-actions button:hover, .portrait-actions button:focus-visible { color: #111; border-color: var(--accent); background: var(--accent); } +.portrait-error { position: absolute; z-index: 4; top: 10px; left: 10px; right: 10px; margin: 0; padding: 7px 9px; color: #fff; background: rgba(139,35,35,.94); font: 8px/1.35 var(--font-mono); } .level-badge { position: absolute; z-index: 2; right: 18px; bottom: 15px; width: 52px; height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #111; background: var(--accent); clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); } .level-badge small { font: 500 7px var(--font-mono); } .level-badge strong { font: 800 19px/1 var(--font-display); } @@ -1078,6 +1084,28 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .dice-window-controls { grid-template-columns: 1fr; } .dice-chat-heading { align-items: stretch; flex-direction: column; } } +@media (min-width: 801px) { + .master-inspector:not(.empty-inspector) { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + } + .master-inspector:not(.empty-inspector) > .sheet-accent, + .master-inspector:not(.empty-inspector) > :nth-child(n + 4) { + grid-column: 1 / -1; + } + .master-inspector:not(.empty-inspector) > .portrait { + width: 180px; + height: 180px; + } + .master-inspector:not(.empty-inspector) > .sheet-title { + min-width: 0; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + } +} + @media (max-width: 800px) { .workspace.table-workspace { grid-template-columns: minmax(0, 1fr); } } @@ -1089,6 +1117,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .builder, .panel, .sheet, .progression-panel { min-width: 0; max-width: 100%; } .sheet { position: static; grid-row: 1; } .portrait { height: 130px; } + .portrait-actions { opacity: 1; transform: none; } .choice-grid { grid-template-columns: 1fr; } .sheet { display: grid; grid-template-columns: 130px 1fr; } .sheet-accent { position: absolute; } @@ -1136,7 +1165,8 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display .sheet { width: 100%; position: static; color: #111; background: #fff; border-color: #777; box-shadow: none; } .sheet-accent { background: #111; } .portrait { height: 150px; background: #eee; } - .portrait > span { color: #aaa; } + .portrait-initial { color: #aaa; } + .portrait-actions, .portrait-error { display: none; } .level-badge, .source-icon { color: #fff; background: #111; } .sheet-title, .combat-grid, .sheet-section, .source-card, .stat-block, .quick-stats > div { border-color: #aaa; } .sheet-title p, .stat-block > span, .stat-block small, .sheet-section-title span, .quick-stats span, .source-card p { color: #444; } diff --git a/src/sheets.gleam b/src/sheets.gleam index 2c3defe..69cc6ef 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -140,6 +140,7 @@ pub type StoredCharacter { created_at: String, updated_at: String, character_name: String, + portrait_url: String, level: Int, class_name: String, subclass_name: String, @@ -192,6 +193,8 @@ pub type Model { character_id: String, character_created_at: String, character_name: String, + portrait_url: String, + portrait_error: String, level: Int, class_name: String, subclass_name: String, @@ -275,6 +278,10 @@ pub type Message { CharacterImportFailed(String) UserClickedPrint UserChangedName(String) + UserClickedPortrait + UserSelectedPortrait(String) + UserPortraitFailed(String) + UserRemovedPortrait UserChangedLevel(String) UserChangedClass(String) UserChangedSubclass(String) @@ -388,6 +395,8 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { character_id:, character_created_at:, character_name: "", + portrait_url: "", + portrait_error: "", level: 1, class_name: "Fighter", subclass_name: "", @@ -549,6 +558,7 @@ fn new_stored_character() -> StoredCharacter { created_at: now, updated_at: now, character_name: "", + portrait_url: "", level: 1, class_name: "Fighter", subclass_name: "", @@ -918,6 +928,21 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ) UserChangedName(name) -> update_and_sync(model, Model(..model, character_name: name)) + UserClickedPortrait -> #( + Model(..model, portrait_error: ""), + browser.import_portrait(UserSelectedPortrait, UserPortraitFailed), + ) + UserSelectedPortrait(portrait_url) -> + update_and_sync(model, Model(..model, portrait_url:, portrait_error: "")) + UserPortraitFailed(reason) -> #( + Model(..model, portrait_error: reason), + effect.none(), + ) + UserRemovedPortrait -> + update_and_sync( + model, + Model(..model, portrait_url: "", portrait_error: ""), + ) UserChangedLevel(value) -> update_and_sync( model, @@ -1789,6 +1814,8 @@ fn load_stored_character( character_id: character.id, character_created_at: character.created_at, character_name: character.character_name, + portrait_url: character.portrait_url, + portrait_error: "", level: character.level, class_name: character.class_name, subclass_name: character.subclass_name, @@ -1914,6 +1941,7 @@ fn stored_character_json(character: StoredCharacter) -> json.Json { #("createdAt", json.string(character.created_at)), #("updatedAt", json.string(character.updated_at)), #("characterName", json.string(character.character_name)), + #("portraitUrl", json.string(character.portrait_url)), #("level", json.int(character.level)), #("className", json.string(character.class_name)), #("subclassName", json.string(character.subclass_name)), @@ -1960,6 +1988,7 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { use created_at <- decode.optional_field("createdAt", "", decode.string) use updated_at <- decode.optional_field("updatedAt", "", decode.string) use character_name <- decode.field("characterName", decode.string) + use portrait_url <- decode.optional_field("portraitUrl", "", decode.string) use level <- decode.field("level", decode.int) use class_name <- decode.field("className", decode.string) use subclass_name <- decode.optional_field("subclassName", "", decode.string) @@ -2034,6 +2063,7 @@ fn stored_character_decoder() -> decode.Decoder(StoredCharacter) { created_at:, updated_at:, character_name:, + portrait_url:, level:, class_name:, subclass_name:, @@ -2171,6 +2201,7 @@ fn model_to_stored_character(model: Model) -> StoredCharacter { created_at: model.character_created_at, updated_at: browser.now_iso(), character_name: model.character_name, + portrait_url: model.portrait_url, level: model.level, class_name: model.class_name, subclass_name: model.subclass_name, @@ -3460,6 +3491,90 @@ fn view_inspected_character(model: Model) -> Element(Message) { } } +fn portrait_content( + portrait_url: String, + display_name: String, +) -> Element(message) { + case portrait_url { + "" -> + html.span([attribute.class("portrait-initial")], [ + html.text(string.slice(display_name, at_index: 0, length: 1)), + ]) + portrait_url -> + html.img([ + attribute.class("portrait-image"), + attribute.src(portrait_url), + attribute.alt(display_name <> " portrait"), + ]) + } +} + +fn view_remote_portrait( + portrait_url portrait_url: String, + display_name display_name: String, + level level: Int, +) -> Element(message) { + html.div([attribute.class("portrait")], [ + portrait_content(portrait_url, display_name), + html.div([attribute.class("level-badge")], [ + html.small([], [html.text("LVL")]), + html.strong([], [html.text(int.to_string(level))]), + ]), + ]) +} + +fn view_character_portrait( + model: Model, + display_name: String, +) -> Element(Message) { + html.div([attribute.class("portrait editable-portrait")], [ + portrait_content(model.portrait_url, display_name), + html.div([attribute.class("portrait-actions")], [ + html.button( + [ + attribute.type_("button"), + attribute.class("portrait-upload-button"), + attribute.aria_label(case model.portrait_url { + "" -> "Add character portrait" + _ -> "Change character portrait" + }), + event.on_click(UserClickedPortrait), + ], + [ + html.text(case model.portrait_url { + "" -> "Add portrait" + _ -> "Change" + }), + ], + ), + case model.portrait_url { + "" -> html.text("") + _ -> + html.button( + [ + attribute.type_("button"), + attribute.class("portrait-remove-button"), + attribute.aria_label("Remove character portrait"), + event.on_click(UserRemovedPortrait), + ], + [html.text("Remove")], + ) + }, + ]), + case model.portrait_error { + "" -> html.text("") + reason -> + html.p([attribute.class("portrait-error"), attribute.role("alert")], [ + html.text(reason), + ]) + }, + html.div([attribute.class("level-badge")], [ + html.small([], [html.text("LVL")]), + html.strong([], [html.text(int.to_string(model.level))]), + ]), + ]) +} + fn view_snapshot(snapshot: Snapshot) -> Element(Message) { let display_name = snapshot_display_name(snapshot) let class_identity = case snapshot.subclass_name { @@ -3468,15 +3583,11 @@ fn view_snapshot(snapshot: Snapshot) -> Element(Message) { } html.aside([attribute.class("sheet master-inspector")], [ html.div([attribute.class("sheet-accent")], []), - html.div([attribute.class("portrait")], [ - html.span([], [ - html.text(string.slice(display_name, at_index: 0, length: 1)), - ]), - html.div([attribute.class("level-badge")], [ - html.small([], [html.text("LVL")]), - html.strong([], [html.text(int.to_string(snapshot.level))]), - ]), - ]), + view_remote_portrait( + portrait_url: snapshot.portrait_url, + display_name:, + level: snapshot.level, + ), html.div([attribute.class("sheet-title inspector-title")], [ html.div([attribute.class("inspector-heading")], [ html.span([attribute.class("eyebrow")], [ @@ -5463,15 +5574,7 @@ fn view_sheet(model: Model) -> Element(Message) { html.aside([attribute.class("sheet")], [ html.div([attribute.class("sheet-accent")], []), - html.div([attribute.class("portrait")], [ - html.span([], [ - html.text(string.slice(display_name, at_index: 0, length: 1)), - ]), - html.div([attribute.class("level-badge")], [ - html.small([], [html.text("LVL")]), - html.strong([], [html.text(int.to_string(model.level))]), - ]), - ]), + view_character_portrait(model, display_name), html.div([attribute.class("sheet-title")], [ html.span([attribute.class("eyebrow")], [html.text("Character preview")]), html.h2([], [html.text(display_name)]), @@ -5995,6 +6098,7 @@ fn to_snapshot(model: Model) -> Snapshot { Snapshot( owner: peer_display_name(model, "Player"), character_name: model.character_name, + portrait_url: model.portrait_url, level: model.level, class_name: model.class_name, subclass_name: model.subclass_name, diff --git a/src/sheets/browser.gleam b/src/sheets/browser.gleam index f3be5f2..6eb5c8b 100644 --- a/src/sheets/browser.gleam +++ b/src/sheets/browser.gleam @@ -21,6 +21,18 @@ pub fn import_json( }) } +pub fn import_portrait( + succeeded: fn(String) -> message, + failed: fn(String) -> message, +) -> Effect(message) { + effect.from(fn(dispatch) { + choose_portrait_file( + fn(contents) { dispatch(succeeded(contents)) }, + fn(reason) { dispatch(failed(reason)) }, + ) + }) +} + pub fn print_page() -> Effect(message) { effect.from(fn(_dispatch) { print() }) } @@ -34,5 +46,11 @@ fn choose_json_file( failed: fn(String) -> Nil, ) -> Nil +@external(javascript, "./browser_ffi.mjs", "choosePortraitFile") +fn choose_portrait_file( + succeeded: fn(String) -> Nil, + failed: fn(String) -> Nil, +) -> Nil + @external(javascript, "./browser_ffi.mjs", "printPage") fn print() -> Nil diff --git a/src/sheets/browser_ffi.mjs b/src/sheets/browser_ffi.mjs index 3a007e0..d255695 100644 --- a/src/sheets/browser_ffi.mjs +++ b/src/sheets/browser_ffi.mjs @@ -35,6 +35,59 @@ export function chooseJsonFile(onSuccess, onError) { input.click(); } +export function choosePortraitFile(onSuccess, onError) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + onError("Choose an image file."); + return; + } + if (file.size > 15 * 1024 * 1024) { + onError("Choose an image smaller than 15 MB."); + return; + } + + const url = URL.createObjectURL(file); + try { + const image = new Image(); + image.src = url; + await image.decode(); + + const size = 512; + const sourceSize = Math.min(image.naturalWidth, image.naturalHeight); + const sourceX = (image.naturalWidth - sourceSize) / 2; + const sourceY = (image.naturalHeight - sourceSize) / 2; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext("2d"); + context.fillStyle = "#151815"; + context.fillRect(0, 0, size, size); + context.drawImage( + image, + sourceX, + sourceY, + sourceSize, + sourceSize, + 0, + 0, + size, + size, + ); + onSuccess(canvas.toDataURL("image/jpeg", 0.82)); + } catch (error) { + onError(`Could not read ${file.name}: ${String(error)}`); + } finally { + URL.revokeObjectURL(url); + } + }, { once: true }); + input.click(); +} + export function printPage() { globalThis.print(); } diff --git a/src/sheets/peer.gleam b/src/sheets/peer.gleam index 3470ba7..0778f66 100644 --- a/src/sheets/peer.gleam +++ b/src/sheets/peer.gleam @@ -59,6 +59,7 @@ pub type Snapshot { Snapshot( owner: String, character_name: String, + portrait_url: String, level: Int, class_name: String, subclass_name: String, @@ -211,6 +212,7 @@ pub fn snapshot_to_json(snapshot: Snapshot) -> String { #("type", json.string("sheet")), #("owner", json.string(snapshot.owner)), #("characterName", json.string(snapshot.character_name)), + #("portraitUrl", json.string(snapshot.portrait_url)), #("level", json.int(snapshot.level)), #("className", json.string(snapshot.class_name)), #("subclassName", json.string(snapshot.subclass_name)), @@ -251,6 +253,7 @@ pub fn snapshot_to_json(snapshot: Snapshot) -> String { fn snapshot_decoder() -> decode.Decoder(Snapshot) { use owner <- decode.field("owner", decode.string) use character_name <- decode.field("characterName", decode.string) + use portrait_url <- decode.optional_field("portraitUrl", "", decode.string) use level <- decode.field("level", decode.int) use class_name <- decode.field("className", decode.string) use subclass_name <- decode.optional_field("subclassName", "", decode.string) @@ -333,6 +336,7 @@ fn snapshot_decoder() -> decode.Decoder(Snapshot) { decode.success(Snapshot( owner:, character_name:, + portrait_url:, level:, class_name:, subclass_name:, diff --git a/test/sheets_test.gleam b/test/sheets_test.gleam index 61372da..25ca528 100644 --- a/test/sheets_test.gleam +++ b/test/sheets_test.gleam @@ -21,6 +21,7 @@ pub fn complete_character_snapshot_round_trip_test() { Snapshot( owner: "Maribel", character_name: "Pepper", + portrait_url: "data:image/jpeg;base64,portrait", level: 4, class_name: "Fighter", subclass_name: "Eldritch Knight",