diff --git a/dev/cljs/wish/config.cljs b/dev/cljs/wish/config.cljs index 090f5424..ed61b5f3 100644 --- a/dev/cljs/wish/config.cljs +++ b/dev/cljs/wish/config.cljs @@ -8,6 +8,7 @@ ^boolean goog.DEBUG) (def server-root "") +(def full-url-root (str "http://localhost:3450" server-root)) (def gdrive-client-id "661182319990-1uerkr0pue6k60a83atj2f58md95fb1b.apps.googleusercontent.com") diff --git a/less/site.less b/less/site.less index c09780ac..8449be9b 100644 --- a/less/site.less +++ b/less/site.less @@ -86,6 +86,10 @@ a { cursor: pointer; } +.metadata() { + font-size: 80%; +} + .scrollable() { overflow-y: scroll; .box-shadow(inset 0 -8px 12px -4px fade(#333, 50%)); @@ -189,10 +193,14 @@ a { } .metadata { - font-size: 80%; + .metadata(); } } +.group { + margin: 8px 0; +} + .sections { .flex(wrap); .justify-content(center); @@ -259,13 +267,20 @@ a { margin-top: 12px; } -.update-notifier { +.notifiers { @media @smartphones { left: 0; right: 0; bottom: 0; } + position: fixed; + right: 24px; + bottom: 24px; + z-index: 2; +} + +.notifier { @keyframes slide-in { from { transform: translateY(100%); @@ -282,13 +297,11 @@ a { background-color: #666666; color: #fff; - position: fixed; - right: 24px; - bottom: 24px; - padding: 4px; vertical-align: center; + margin-top: 8px; + .ignore { .justify-content(center); @@ -307,12 +320,104 @@ a { .flex-grow(); } - .update { + .action { margin-right: 8px; padding: 8px; } } +// +// Campaign screen widgets + +.add-chars-overlay { + padding: 32px; + + .desc { + .metadata(); + padding: 8px; + } + + .character { + .flex(); + .align-items(center); + + width: 100%; + margin: 8px 0; + + .name { + width: 45%; + } + } +} + +.carousel-container { + width: 100%; + overflow-x: auto; + + .carousel { + .flex(); + + a.add-button { + .flex(); + .flex-direction(row); + .align-items(center); + + padding: 8px; + } + + a.card { + color: inherit; + margin: 8px 4px; + padding: 0px; + } + div.card { + background-color: #eee; + border-radius: 4px; + padding: 8px; + text-align: center; + width: 220px; + + &:hover { + background-color: #ddd; + } + + &:active { + background-color: #ccc; + } + } + } +} + +.hp-bar { + @height: 32pt; + + position: relative; + background-color: #fcfcfc; + border-radius: 4px; + height: @height; + margin: 8px 0; + + .bar { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 4px; + + transition: all 450ms cubic-bezier(0, 0, 0.2, 1); + } + + .label { + font-size: 16pt; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + line-height: @height; + } +} + // // shared widgets diff --git a/prod/cljs/wish/config.cljs b/prod/cljs/wish/config.cljs index bd4d5353..5470a037 100644 --- a/prod/cljs/wish/config.cljs +++ b/prod/cljs/wish/config.cljs @@ -9,6 +9,9 @@ ; IE: https://dhleong.github.io/wish (def server-root "/wish") +; TODO good-define? +(def full-url-root (str "https://dhleong.github.io" server-root)) + (def gdrive-client-id "661182319990-3aa8akj9fh8eva9lf7bt02621q2i18s6.apps.googleusercontent.com") (def push-server "https://wish-server.now.sh") diff --git a/src/cljs/wish/db.cljs b/src/cljs/wish/db.cljs index d59eff4f..83240887 100644 --- a/src/cljs/wish/db.cljs +++ b/src/cljs/wish/db.cljs @@ -39,4 +39,6 @@ ::save-errors #{} ::pending-saves #{} - ::processing-saves #{}}) + ::processing-saves #{} + + :notifications {}}) diff --git a/src/cljs/wish/events.cljs b/src/cljs/wish/events.cljs index eda0e2f2..7ad87c5d 100644 --- a/src/cljs/wish/events.cljs +++ b/src/cljs/wish/events.cljs @@ -30,6 +30,12 @@ :dispatch-n [[::update-keymap page-spec] [:push/check]]})) +(reg-event-fx + :nav/replace! + [trim-v] + (fn-traced [_ [new-location]] + {:nav/replace! new-location})) + (reg-event-db :set-device [trim-v] @@ -135,6 +141,36 @@ :ready)})) +; ======= notifications =================================== + +(reg-event-fx + :notify! + [trim-v] + (fn [{:keys [db]} [{:keys [duration duration-ms content + dismissable?] + :or {dismissable? true}}]] + (let [created (js/Date.now) + id (keyword (str created))] + {:db (assoc-in db [:notifications id] + {:id id + :created created + :content content + :dismiss-event (when (or (nil? duration) + dismissable?) + [::remove-notify! id])}) + :dispatch-later [(when (or duration duration-ms) + {:ms (case duration + :short 3000 + :long 7500 + duration-ms) + :dispatch [::remove-notify! id]})]}))) + +(reg-event-db + ::remove-notify! + [trim-v (path :notifications)] + (fn-traced [notifications [id]] + (dissoc notifications id))) + ; ======= Provider management ============================== @@ -259,7 +295,11 @@ [trim-v] (fn-traced [{:keys [db]} [sheet-id sheet]] {:db (-> db - (assoc-in [:sheets sheet-id] sheet) + (update-in [:sheets sheet-id] + (fn [old-value] + (merge + (select-keys old-value [:type]) + sheet))) (reset-sheet-err sheet-id)) ; NOTE: we're probably on a :sheet page, and we might not have @@ -757,4 +797,3 @@ (when (contains? changed-ids active-sheet-id) ; trigger sheet data reload {:load-sheet! active-sheet-id}))) - diff --git a/src/cljs/wish/fx.cljs b/src/cljs/wish/fx.cljs index b8769a09..0ba48f71 100644 --- a/src/cljs/wish/fx.cljs +++ b/src/cljs/wish/fx.cljs @@ -22,6 +22,10 @@ (log "document.title <-" title) (set! js/document.title title))) +(reg-fx + :nav/replace! + nav/replace!) + ; ======= keymaps ========================================= diff --git a/src/cljs/wish/providers.cljs b/src/cljs/wish/providers.cljs index a481254b..7c6fe7fa 100644 --- a/src/cljs/wish/providers.cljs +++ b/src/cljs/wish/providers.cljs @@ -111,15 +111,20 @@ (let [[provider-id _] (unpack-id sheet-id)] (provider-key provider-id :share!))) -(defn create-sheet-with-data - "Returns a channel that emits [err sheet-id] on success" - [sheet-name provider-id data] +(defn create-file-with-data + "Returns a channel that emits [err sheet-id] on success. + `kind` may be one of: + - :campaign + - :sheet" + [kind sheet-name provider-id data] {:pre [(not (nil? provider-id)) - (not (nil? data))]} + (not (nil? data)) + (contains? #{:campaign :sheet} kind)]} (if-let [inst (provider-key provider-id :inst)] - (provider/create-sheet inst - sheet-name - data) + (provider/create-file inst + kind + sheet-name + data) (throw (js/Error. (str "No provider instance for " provider-id))))) diff --git a/src/cljs/wish/providers/caching.cljs b/src/cljs/wish/providers/caching.cljs index 2ec5e73e..8fe955cf 100644 --- a/src/cljs/wish/providers/caching.cljs +++ b/src/cljs/wish/providers/caching.cljs @@ -49,8 +49,8 @@ (deftype CachingProvider [base my-id storage dirty?-storage] IProvider (id [this] my-id) - (create-sheet [this file-name data] - (provider/create-sheet base file-name data)) + (create-file [this kind file-name data] + (provider/create-file base kind file-name data)) (init! [this] (go (let [base-state (or ( raw-file - (select-keys [:name]) - (assoc :mine? (:ownedByMe raw-file)))]))))] + {:name (:name raw-file) + :mine? (:ownedByMe raw-file) + :type (-> raw-file + :appProperties + :wish-type + (subs (count "wish-")) + keyword)}]))))] (-> js/gapi.client.drive.files (.list #js {:q q :pageSize page-size :spaces "drive" - :fields "nextPageToken, files(id, name, ownedByMe)"}) + :fields "nextPageToken, files(id, name, ownedByMe, appProperties)"}) (promise->chan 1 (map (fn [[err resp :as r]] (if resp ; success! diff --git a/src/cljs/wish/providers/wish.cljs b/src/cljs/wish/providers/wish.cljs index 0110e992..cb365fb5 100644 --- a/src/cljs/wish/providers/wish.cljs +++ b/src/cljs/wish/providers/wish.cljs @@ -21,7 +21,7 @@ (deftype WishProvider [] IProvider (id [this] :wish) - (create-sheet [this file-name data] + (create-file [this kind file-name data] (to-chan [[(js/Error. "Not implemented") nil]])) (init! [this] diff --git a/src/cljs/wish/routes.cljs b/src/cljs/wish/routes.cljs index a7d8b93c..3eea670e 100644 --- a/src/cljs/wish/routes.cljs +++ b/src/cljs/wish/routes.cljs @@ -1,17 +1,36 @@ (ns wish.routes (:require-macros [secretary.core :refer [defroute]]) (:require [pushy.core :as pushy] + [secretary.core :as secretary] [wish.util.nav :as nav :refer [hook-browser-navigation! navigate!]])) -(defn app-routes [] - (nav/init!) +(defn- def-routes [] + (secretary/reset-routes!) + + ;; + ;; app routes declared here: - ;; -------------------- - ;; define routes here (defroute "/" [] (navigate! :home)) + (defroute "/campaigns" [] + (navigate! :campaign-browser)) + + (defroute "/campaigns/new" [] + (navigate! :new-campaign)) + + (defroute #"/campaigns/([a-z0-9-]+/[^/]+)" [id] + (navigate! :campaign [(keyword id)])) + + (defroute #"/join-campaign/([a-z0-9-]+/[^/]+)(/n/[^/]+)?/as/(.*)" [campaign-id label sheet-id] + (navigate! :join-campaign [(keyword campaign-id) + (keyword sheet-id) + (when-not (empty? label) + (js/decodeURIComponent + ; trim off /n/ + (subs label 3)))])) + (defroute "/sheets" [] (navigate! :sheet-browser)) @@ -29,7 +48,12 @@ (defroute "/providers/:provider-id/config" [provider-id] (navigate! :provider-config (keyword provider-id))) + ) + +(defn app-routes [] + (nav/init!) + + (def-routes) - ;; -------------------- (hook-browser-navigation!)) diff --git a/src/cljs/wish/sheets.cljs b/src/cljs/wish/sheets.cljs index faaa8d30..4198819e 100644 --- a/src/cljs/wish/sheets.cljs +++ b/src/cljs/wish/sheets.cljs @@ -3,10 +3,11 @@ wish.sheets (:require [wish.sheets.dnd5e :as dnd5e] [wish.sheets.dnd5e.builder :as dnd5e-builder] + [wish.sheets.dnd5e.campaign :as dnd5e-campaign] [wish.sheets.dnd5e.keymaps :as dnd5e-key] [wish.sheets.dnd5e.util :as dnd5e-util] [wish.sources.compiler :refer [compiler-version]] - [wish.providers :refer [create-sheet-with-data + [wish.providers :refer [create-file-with-data error-resolver-view]] [wish.util :refer [click>evt evt]] [wish.views.error-boundary :refer [error-boundary]] @@ -20,6 +21,7 @@ {:dnd5e {:name "D&D 5E" :fn #'dnd5e/sheet :builder #'dnd5e-builder/view + :campaign #'dnd5e-campaign/view :v 1 :default-sources [:wish/wdnd5e-srd] @@ -63,6 +65,25 @@ ; no processor for this sheet; pass through data)) +(defn stub-campaign + "Create the initial data for a new campaign" + [kind campaign-name] + (let [kind-meta (get sheets kind)] + (when-not kind-meta + (throw (js/Error. + (str "Unable to get sheet meta for kind: " kind)))) + + {:v [compiler-version (:v kind-meta)] ; wish + sheet version numbers + :updated (.getTime (js/Date.)) ; date + :kind kind + + :name campaign-name + + :sources (:default-sources kind-meta) + + :players #{} + })) + (defn stub-sheet "Create the initial data for a new sheet" [kind sheet-name] @@ -87,13 +108,21 @@ :equipped #{} })) +(defn create-campaign! + "Returns a channel that emits [err sheet-id] on success" + [campaign-name provider-id sheet-kind] + {:pre [(not (nil? provider-id)) + (not (nil? sheet-kind))]} + (create-file-with-data :campaign campaign-name provider-id + (stub-campaign sheet-kind campaign-name))) + (defn create-sheet! "Returns a channel that emits [err sheet-id] on success" [sheet-name provider-id sheet-kind] {:pre [(not (nil? provider-id)) (not (nil? sheet-kind))]} - (create-sheet-with-data sheet-name provider-id - (stub-sheet sheet-kind sheet-name))) + (create-file-with-data :sheet sheet-name provider-id + (stub-sheet sheet-kind sheet-name))) ; ======= Views ============================================ @@ -169,12 +198,19 @@ [[sheet-id section]] (ensuring-loaded sheet-id - (fn [sheet-info] - [(:builder sheet-info) section]))) + (fn [{view :builder}] + [view section]))) + +(defn campaign + [[campaign-id section]] + (ensuring-loaded + campaign-id + (fn [{view :campaign}] + [view section]))) (defn viewer [sheet-id] (ensuring-loaded sheet-id - (fn [sheet-info] - [(:fn sheet-info)]))) + (fn [{view :fn}] + [view]))) diff --git a/src/cljs/wish/sheets/dnd5e.cljs b/src/cljs/wish/sheets/dnd5e.cljs index 976f31ea..9894b926 100644 --- a/src/cljs/wish/sheets/dnd5e.cljs +++ b/src/cljs/wish/sheets/dnd5e.cljs @@ -58,14 +58,16 @@ (icon :radio-button-unchecked.icon {:class icon-class})) {:key i}))]) -(defn- hp-death-saving-throws [] - (let [{:keys [saves fails]} ( - [save-indicators "😇" :save saves] - [save-indicators "☠️" :fail fails]])) +(defn hp-death-saving-throws + ([] (hp-death-saving-throws nil)) + ([sheet-id] + (let [{:keys [saves fails]} ( + [save-indicators "😇" :save saves] + [save-indicators "☠️" :fail fails]]))) (defn hp [] - (let [[hp max-hp] (evt [:toggle-overlay [#'overlays/hp-overlay]])} @@ -154,26 +156,33 @@ [:wis "WIS"] [:cha "CHA"]]) +(defn abilities-display + ([abilities] (abilities-display abilities false)) + ([abilities clickable?] + [:<> + (for [[id label] labeled-abilities] + (let [{:keys [score modifier mod]} (get abilities id)] + ^{:key id} + [:div.ability {:class (when mod + (case mod + :buff "buffed" + :nerf "nerfed")) + :on-click (when clickable? + (click>evt [:toggle-overlay + [#'overlays/ability-tmp + id + label]]))} + [:div.label label] + [:div.mod modifier] + [:div.score "(" score ")"] + ]))])) + (defn abilities-section [] (let [abilities (evt [:toggle-overlay - [#'overlays/ability-tmp - id - label]])} - [:div.label label] - [:div.mod modifier] - [:div.score "(" score ")"] - ]))] + [abilities-display abilities :clickable]] [:div.info "Saves"] diff --git a/src/cljs/wish/sheets/dnd5e/builder.cljs b/src/cljs/wish/sheets/dnd5e/builder.cljs index 66a48bef..2031a572 100644 --- a/src/cljs/wish/sheets/dnd5e/builder.cljs +++ b/src/cljs/wish/sheets/dnd5e/builder.cljs @@ -15,7 +15,9 @@ [wish.style :refer-macros [defclass defstyled]] [wish.style.flex :as flex :refer [flex]] [wish.style.shared :as style] - [wish.views.sheet-builder-util :refer [data-source-manager router + [wish.views.sheet-builder-util :refer [campaign-manager + data-source-manager + router count-max-options]] [wish.views.widgets :refer [formatted-text]] [wish.views.widgets.dynamic-list] @@ -82,18 +84,22 @@ (defn home-page [] [:div - [:h3 "Home" - [bind-fields - [:div - [:input {:field :text - :id :name}] ] + [:h3 "Home"] + [bind-fields + [:div + [:input {:field :text + :id :name}] ] - {:get #(get-in (evt [:update-meta path (constantly v)]))}] + {:get #(get-in (evt [:update-meta path (constantly v)]))}] - ; data source mgmt - [data-source-manager]]]) + ; campaign mgmt + [campaign-manager] + + ; data source mgmt + [data-source-manager] + ]) (defn- feature-option [option selected?] diff --git a/src/cljs/wish/sheets/dnd5e/campaign.cljs b/src/cljs/wish/sheets/dnd5e/campaign.cljs new file mode 100644 index 00000000..9a3955ba --- /dev/null +++ b/src/cljs/wish/sheets/dnd5e/campaign.cljs @@ -0,0 +1,32 @@ +(ns ^{:author "Daniel Leong" + :doc "Campaign-viewer for D&D 5e"} + wish.sheets.dnd5e.campaign + (:require [wish.sheets.dnd5e.subs :as subs] + [wish.sheets.dnd5e :as dnd5e] + [wish.sheets.dnd5e.campaign.style :as style] + [wish.views.campaign.base :as base] + [wish.views.campaign.hp-bar :refer [hp-bar]] + [wish.views.widgets :as widgets + :refer-macros [icon] + :refer [link link>evt]] + [wish.util :refer [evt]])) + +(defn char-card [{:keys [id] :as c}] + [:div style/char-card + [:div.name (:name c)] + + [:div.hp + (let [[hp max-hp] ( hp 0) + [hp-bar hp max-hp] + [dnd5e/hp-death-saving-throws id]))] + + [:div.abilities + (let [info (str]] [wish.sheets.dnd5e.builder.data :refer [point-buy-max score-point-cost]] + [wish.subs-util :refer [reg-id-sub query-vec->preferred-id]] [wish.util :refer [map]] [wish.util.string :as wstr])) @@ -91,7 +92,7 @@ "Convenience for creating a sub that just gets a specific field from the :sheet key of the sheet-meta" [id getter] - (reg-sub + (reg-id-sub id :<- [:meta/sheet] (fn [sheet _] @@ -143,7 +144,7 @@ ; ======= class and level ================================== -(reg-sub +(reg-id-sub ::class->level :<- [:classes] (fn [classes _] @@ -153,19 +154,19 @@ {} classes))) -(reg-sub +(reg-id-sub ::class-level :<- [::class->level] (fn [classes [_ class-id]] (get classes class-id))) -(reg-sub +(reg-id-sub ::abilities-raw :<- [:meta/sheet] (fn [sheet] (:abilities sheet))) -(reg-sub +(reg-id-sub ::abilities-improvements :<- [:classes] :<- [:races] @@ -175,7 +176,7 @@ (map (comp :buffs :attrs)) (apply merge-with +)))) -(reg-sub +(reg-id-sub ::abilities-racial :<- [:race] (fn [race] @@ -188,7 +189,7 @@ ; TODO when we do handle equippable item buffs here, we need ; to make sure ::available-classes doesn't use it (only ability ; score improvements and racial bonuses ...) -(reg-sub +(reg-id-sub ::abilities-base :<- [::abilities-raw] :<- [::abilities-racial] @@ -199,7 +200,7 @@ race improvements))) -(reg-sub +(reg-id-sub ::abilities :<- [::abilities-base] :<- [:meta/sheet] @@ -208,7 +209,7 @@ base (:ability-tmp sheet)))) -(reg-sub +(reg-id-sub ::ability-modifiers :<- [::abilities] (fn [abilities] @@ -218,7 +219,7 @@ {} abilities))) -(reg-sub +(reg-id-sub ::ability-saves :<- [::ability-modifiers] :<- [::proficiency-bonus] @@ -238,7 +239,7 @@ {} modifiers))) -(reg-sub +(reg-id-sub ::ability-info :<- [::abilities] :<- [::abilities-base] @@ -262,7 +263,7 @@ {} abilities))) -(reg-sub +(reg-id-sub ::skill-info :<- [::ability-modifiers] :<- [::skill-expertise] @@ -294,7 +295,7 @@ {} data/skill-id->ability))) -(reg-sub +(reg-id-sub ::limited-uses :<- [:limited-uses] :<- [:total-level] @@ -355,13 +356,17 @@ :max-slots (:total input)} ))) -(reg-sub +(reg-id-sub ::rolled-hp :<- [:meta/sheet] (fn [sheet [_ ?path]] (get-in sheet (concat [:hp-rolled] - ?path)))) + + ; NOTE: as an id-sub, we can also be called + ; where the var at this position is the sheet id + (when (coll? ?path) + ?path))))) (reg-sheet-sub ::temp-hp @@ -372,7 +377,7 @@ :temp-max-hp) (def ^:private compile-hp-buff (memoize ->callable)) -(reg-sub +(reg-id-sub ::max-hp-buffs :<- [:race] :<- [:classes] @@ -391,7 +396,7 @@ entity))) (apply +)))) -(reg-sub +(reg-id-sub ::max-hp-mode :<- [:meta/sheet] (fn [sheet] @@ -404,7 +409,7 @@ ; default to :average for new users :average))) -(reg-sub +(reg-id-sub ::max-hp-rolled :<- [::rolled-hp] :<- [::class->level] @@ -431,7 +436,7 @@ (apply +)))) -(reg-sub +(reg-id-sub ::max-hp-average :<- [:classes] (fn [classes] @@ -456,24 +461,23 @@ 0 ; start at 0 classes))) -; NOTE: we use reg-sub-raw here since it feels wrong to -(reg-sub +(reg-id-sub ::max-hp - (fn [] + (fn [query-vec] [; NOTE: this preferred-id query-vec)]) + :manual [::max-hp-rolled] + :average [::max-hp-average]) + + [::temp-max-hp] + [::abilities] + [:total-level] + [::max-hp-buffs] ]) - (fn [[base-max temp-max abilities total-level buffs]] + (fn [[base-max temp-max abilities total-level buffs :as in]] (+ base-max temp-max @@ -485,7 +489,7 @@ buffs))) -(reg-sub +(reg-id-sub ::hp :<- [::temp-hp] :<- [::max-hp] diff --git a/src/cljs/wish/subs.cljs b/src/cljs/wish/subs.cljs index 4de50c7e..1bef5115 100644 --- a/src/cljs/wish/subs.cljs +++ b/src/cljs/wish/subs.cljs @@ -6,7 +6,7 @@ [wish.db :as db] [wish.inventory :as inv] [wish.providers :as providers] - [wish.subs-util :refer [active-sheet-id]] + [wish.subs-util :refer [active-sheet-id reg-id-sub]] [wish.sheets :as sheets] [wish.sources.compiler :refer [apply-directives inflate]] [wish.sources.compiler.lists :as lists] @@ -16,6 +16,14 @@ (reg-sub :device-type :device-type) (reg-sub :showing-overlay :showing-overlay) +(reg-sub + :notifications + (fn [db] + (->> db + :notifications + vals + (sort-by :created)))) + (reg-sub :update-available? (fn [{{:keys [latest ignored]} :updates} _] @@ -87,7 +95,7 @@ ; each part of the sheet-meta, to avoid a small edit to HP, ; for example, causing all of the spell lists and features ; (which rely on classes, etc.) to be re-calculated - (reg-sub + (reg-id-sub id :<- [:sheet-meta] (fn [sheet _] @@ -99,6 +107,8 @@ (reg-sub :sheets-filters :sheets-filters) (reg-sub :sheet-sources :sheet-sources) +; sheets +(reg-meta-sub :meta/name :name) (reg-meta-sub :meta/sheet :sheet) (reg-meta-sub :meta/sources :sources) (reg-meta-sub :meta/kind :kind) @@ -109,6 +119,10 @@ (reg-meta-sub :meta/inventory :inventory) (reg-meta-sub :meta/items :items) (reg-meta-sub :meta/equipped :equipped) +(reg-meta-sub :meta/campaign :campaign) + +; campaigns +(reg-meta-sub :meta/players :players) (reg-sub :active-sheet-source-ids @@ -118,8 +132,14 @@ (reg-sub :active-sheet-id :<- [:page] - (fn [page-vec _] - (active-sheet-id nil page-vec))) + (fn [page-vec [_ ?requested-id]] + ; NOTE: subscriptions created with wish.sub-util/reg-id-sub + ; can accept an extra param in their query vector that will + ; get passed down to us as ?requested-id, if provided; if + ; not, we just do the normal thing and extract the + ; active-sheet-id from the page vector + (or ?requested-id + (active-sheet-id nil page-vec)))) (reg-sub :sharable-sheet-id @@ -141,7 +161,7 @@ :id sheet-id))) (reg-sub - :known-sheets + ::known-files :<- [:sheets] :<- [:my-sheets] (fn [[sheets my-sheets] _] @@ -164,6 +184,15 @@ (filter :name) (sort-by :name)))) +(reg-sub + :known-sheets + :<- [::known-files] + (fn [all-files _] + (->> all-files + (filter (comp (partial = :sheet) + :type)) + (sort-by :name)))) + (reg-sub :filtered-known-sheets :<- [:known-sheets] @@ -180,9 +209,19 @@ (:shared? filters) (remove :mine? sheets)))) +(reg-sub + :known-campaigns + :<- [::known-files] + (fn [all-files _] + (->> all-files + (filter (comp (partial = :campaign) + :type)) + (sort-by :name)))) + + ; if a specific sheet-id is not provided, loads ; for the active sheet id -(reg-sub +(reg-id-sub :sheet-source :<- [:sheet-sources] :<- [:active-sheet-id] @@ -205,14 +244,14 @@ ; ======= Accessors for the active sheet =================== -(reg-sub +(reg-id-sub :sheet-meta :<- [:sheets] :<- [:active-sheet-id] (fn [[sheets id]] (get sheets id))) -(reg-sub +(reg-id-sub :classes :<- [:meta/kind] :<- [:sheet-source] @@ -235,7 +274,7 @@ ; A single class instance, or nil if none at all; if any ; class is marked primary, that class is returned. If none ; are so marked, then nil is returned -(reg-sub +(reg-id-sub :primary-class :<- [:classes] (fn [classes] @@ -244,13 +283,13 @@ first))) ; sum of levels from all classes -(reg-sub +(reg-id-sub :total-level :<- [:classes] (fn [classes _] (apply + (map :level classes)))) -(reg-sub +(reg-id-sub :races :<- [:sheet-meta] :<- [:sheet-source] @@ -277,7 +316,7 @@ :race)))))))) ; combines :attrs from all classes and races into a single map -(reg-sub +(reg-id-sub :all-attrs :<- [:classes] :<- [:races] @@ -447,7 +486,7 @@ sheet data-source])) -(reg-sub +(reg-id-sub :class-features :<- [:classes] get-features) @@ -474,12 +513,12 @@ (subscribe [:sheet-source])]) only-feature-options) -(reg-sub +(reg-id-sub :race-features :<- [:races] get-features) -(reg-sub +(reg-id-sub :inflated-race-features :<- [:race-features] :<- [:meta/options] @@ -488,7 +527,7 @@ :<- [:sheet-source] inflate-feature-options) -(reg-sub +(reg-id-sub :race-features-with-options :<- [:race-features] :<- [:meta/options] @@ -498,7 +537,7 @@ only-feature-options) ; semantic convenience for single-race systems -(reg-sub +(reg-id-sub :race :<- [:races] (fn [races _] @@ -514,7 +553,7 @@ :wish/context-type kind :wish/context entity))))) -(reg-sub +(reg-id-sub :limited-uses :<- [:classes] :<- [:races] @@ -530,7 +569,7 @@ vals (map (partial uses-with-context :item))))))) -(reg-sub +(reg-id-sub :limited-uses-map :<- [:limited-uses] (fn [limited-uses] @@ -566,7 +605,7 @@ ; will always be the (surprise) item-id. ; In addition, every item in :equipped will have the :wish/equipped? ; set to true -(reg-sub +(reg-id-sub :inventory-map :<- [:meta/kind] :<- [:meta/inventory] @@ -599,7 +638,7 @@ raw-inventory))) ; sorted list of inflated inventory items -(reg-sub +(reg-id-sub :inventory-sorted :<- [:inventory-map] (fn [inventory-map] @@ -608,7 +647,7 @@ (sort-by :name)))) ; sorted list of inflated + equipped inventory items -(reg-sub +(reg-id-sub :equipped-sorted :<- [:inventory-sorted] (fn [inventory-sorted] @@ -639,7 +678,6 @@ (fn [source [_ entity-kind]] (src/list-entities source entity-kind))) - (reg-sub :options-> :<- [:meta/options] @@ -651,6 +689,7 @@ instanced-value v)))) + ; ======= Save state ======================================= (reg-sub diff --git a/src/cljs/wish/subs_util.cljs b/src/cljs/wish/subs_util.cljs index 41e558f1..c3dba01a 100644 --- a/src/cljs/wish/subs_util.cljs +++ b/src/cljs/wish/subs_util.cljs @@ -1,6 +1,8 @@ (ns ^{:author "Daniel Leong" :doc "subs-util"} - wish.subs-util) + wish.subs-util + (:require-macros [wish.util.log :as log :refer [log]]) + (:require [re-frame.core :refer [reg-sub subscribe]])) (defn active-sheet-id [db & [page-vec]] @@ -8,9 +10,88 @@ (:page db))] (let [[page args] page-vec] (case page + :campaign (first args) + :join-campaign (second args) :sheet args :sheet-builder (first args) ; else, no sheet nil)))) +(defonce ^:private id-subs (atom #{})) +(defn- id-sub? [query-vec] + (let [query-id (first query-vec)] + (or (= :active-sheet-id query-id) + (contains? @id-subs query-id)))) + +(defn query-vec->preferred-id [query-vec] + ; TODO handle normal arguments? We can't + ; just use (last), since if no args are passed + ; that will just return the query-id + (second query-vec)) + +(defn inject-preferred-id [vec preferred-sheet-id] + (if preferred-sheet-id + (conj vec preferred-sheet-id) + vec)) + +(defn reg-id-sub + "This is a drop-in replacement for a subscription which + ultimately depends on [:active-sheet-id], which optionally + takes a single parameter in the query vector indicating + the sheet-id that should be used instead of the result of + [:active-sheet-id]" + [query-id & args] + (let [computation-fn (last args) + input-args (butlast args) + err-header (str "(reg-id-sub " query-id ")") + inputs-fn (case (count input-args) + ; error case + 0 nil + + ; single function; just pass through + 1 (let [base-fn (first input-args)] + (fn inp-fn [query-vec] + (let [base-subs (base-fn query-vec) + actual-sheet-id (query-vec->preferred-id query-vec)] + ; verify sanity + (if-not (every? vector? base-subs) + (do (log/err err-header "should return query vectors, not subscriptions") + ; use them, I guess + base-subs) + + (->> base-subs + (map #(inject-preferred-id % actual-sheet-id)) + (map subscribe)))))) + + ; single sugar pair + 2 (let [[marker vec] input-args] + (when-not (= :<- marker) + (log/err err-header "expected :<-, got:" marker)) + (fn inp-fn [query-vec] + (let [actual-sheet-id (query-vec->preferred-id query-vec)] + (subscribe (inject-preferred-id vec actual-sheet-id))))) + + ; multiple sugar pairs + (let [pairs (partition 2 input-args) + markers (map first pairs) + vecs (map last pairs) + any-id-subs? (some id-sub? vecs)] + (when-not (and (every? #{:<-} markers) (every? vector? vecs)) + (log/err err-header "expected pairs of :<- and vectors, got:" pairs)) + + (fn inp-fn [query-vec] + (let [actual-sheet-id (query-vec->preferred-id query-vec)] + (->> vecs + (map #(inject-preferred-id % actual-sheet-id)) + (map subscribe))))) + )] + (if-not inputs-fn + (log/warn err-header "must have input args") + + (do + (swap! id-subs conj query-id) + (reg-sub + query-id + inputs-fn + computation-fn))))) diff --git a/src/cljs/wish/util/nav.cljs b/src/cljs/wish/util/nav.cljs index 81142f6b..3710c2cf 100644 --- a/src/cljs/wish/util/nav.cljs +++ b/src/cljs/wish/util/nav.cljs @@ -8,6 +8,7 @@ [goog.events :as gevents] [goog.history.EventType :as HistoryEventType] [pushy.core :as pushy] + [wish.config :as config] [wish.util :refer [is-ios? >evt]]) (:import goog.History)) @@ -86,19 +87,46 @@ (js/window.location.replace (prefix new-location))) + +(defn- base-sheet-url + "Generate the url to a sheet, optionally with + extra path sections after it" + [kind id & extra-sections] + (apply str "/" kind + "/" (namespace id) + "/" (name id) + (when extra-sections + (interleave (repeat "/") + (map + #(if (keyword? %) + (name %) + (str %)) + extra-sections))))) + +(defn campaign-url + "Generate the url to a campaign, optionally with + extra path sections after it" + [id & extra-sections] + (apply base-sheet-url "campaigns" id extra-sections)) + +(defn campaign-invite-url + "Generate the url to join a campaign" + ([campaign-id invited-sheet-url] + (campaign-invite-url campaign-id invited-sheet-url nil)) + ([campaign-id invited-sheet-url campaign-name] + (str + config/full-url-root + (prefix + (base-sheet-url "join-campaign" campaign-id + "n" (js/encodeURIComponent campaign-name) + "as" + (namespace invited-sheet-url) invited-sheet-url))))) + (defn sheet-url "Generate the url to a sheet, optionally with extra path sections after it" [id & extra-sections] - (apply str "/sheets/" (namespace id) - "/" (name id) - (when extra-sections - (interleave (repeat "/") - (map - #(if (keyword? %) - (name %) - (str %)) - extra-sections))))) + (apply base-sheet-url "sheets" id extra-sections)) ; ======= support back button to close overlays =========== @@ -129,4 +157,3 @@ ; stop listening to onpopstate (set! js/window.onpopstate nil) (js/history.go -1)))) - diff --git a/src/cljs/wish/views.cljs b/src/cljs/wish/views.cljs index fb2f98eb..2d6a28b2 100644 --- a/src/cljs/wish/views.cljs +++ b/src/cljs/wish/views.cljs @@ -7,18 +7,26 @@ [wish.subs :as subs] [wish.util :refer [evt]] [wish.views.error-boundary :refer [error-boundary]] + [wish.views.campaign-browser :as campaign-browser] + [wish.views.campaign.join :as join-campaign] + [wish.views.error-boundary :refer [error-boundary]] [wish.views.home :refer [home]] + [wish.views.new-campaign :as new-campaign] [wish.views.new-sheet :refer [new-sheet-page]] [wish.views.router :refer [router]] [wish.views.sheet-browser :as sheet-browser] [wish.views.splash :as splash] - [wish.views.update-notifier :refer [update-notifier]] + [wish.views.notifiers :refer [notifiers]] [wish.views.widgets :refer [link] :refer-macros [icon]] [wish.views.widgets.media-tracker :refer [media-tracker]] )) (def pages - {:home #'home + {:campaign #'sheets/campaign + :campaign-browser #'campaign-browser/page + :home #'home + :join-campaign #'join-campaign/page + :new-campaign #'new-campaign/page :new-sheet #'new-sheet-page :sheet #'sheets/viewer :sheet-browser #'sheet-browser/page @@ -61,4 +69,4 @@ [overlay] - [update-notifier]]) + [notifiers]]) diff --git a/src/cljs/wish/views/campaign/base.cljs b/src/cljs/wish/views/campaign/base.cljs new file mode 100644 index 00000000..052cadf4 --- /dev/null +++ b/src/cljs/wish/views/campaign/base.cljs @@ -0,0 +1,24 @@ +(ns ^{:author "Daniel Leong" + :doc "base"} + wish.views.campaign.base + (:require [wish.views.campaign.chars-carousel :refer [chars-carousel]] + [wish.views.error-boundary :refer [error-boundary]])) + +(defn campaign-page + [section & {:keys [char-card]}] + [error-boundary + [:div.campaign + [chars-carousel char-card] + + [:div.info + "This is the campaign page." + + [:div.group + [:a {:href "https://github.com/dhleong/wish/issues/69" + :target '_blank} + "More will be coming here"] + "... eventually."] + + [:div.group + "In the meantime, use the + button above to add characters to this campaign."] + ]]]) diff --git a/src/cljs/wish/views/campaign/chars_carousel.cljs b/src/cljs/wish/views/campaign/chars_carousel.cljs new file mode 100644 index 00000000..66aa4fb8 --- /dev/null +++ b/src/cljs/wish/views/campaign/chars_carousel.cljs @@ -0,0 +1,108 @@ +(ns ^{:author "Daniel Leong" + :doc "campaign.chars-carousel"} + wish.views.campaign.chars-carousel + (:require [wish.util :refer [evt]] + [wish.util.nav :as nav :refer [sheet-url]] + [wish.views.error-boundary :refer [error-boundary]] + [wish.views.widgets :refer [icon link link>evt]] + [wish.views.campaign.events :as events] + [wish.views.campaign.subs :as subs])) + +(defn- sheet-loader [sheet] + [:div "Loading " (:name sheet) "..."]) + +(defn- sources-loader [sheet] + [:div "Loading " (:name sheet) "..."]) + +(defn- char-sheet-loader + [sheet-id content-fn] + (let [sheet (evt [:load-sheet-source! sheet (:sources sheet)]) + [sources-loader sheet])) + + ; either we don't have the sheet at all, or it's just + ; a stub with no actual data; either way, load it! + (do + (>evt [:load-sheet! sheet-id]) + [sheet-loader sheet])))) + +(defn add-chars-overlay [] + (let [campaign-id (evt [:notify! {:duration :short + :content "Copied to clipboard!"}]) + (catch :default e + (println "Unable to copy to clipboard")))) + :value (nav/campaign-invite-url + campaign-id + (:id c) + campaign-name)}] + + [:div.remove + [link>evt [::events/remove-player (:id c)] + (icon :close)]] + ]) + + (for [c (evt [::events/add-player (:id c)] + "Add to campaign"]] + ])] + ])) + +(defn chars-carousel [chars-card-view] + (if-let [members (seq (evt {:> [:toggle-overlay [#'add-chars-overlay]] + :class "add-button"} + (icon :add)] + + ]] + + [:div.empty-carousel + "No characters in this campaign... yet!"])) diff --git a/src/cljs/wish/views/campaign/events.cljs b/src/cljs/wish/views/campaign/events.cljs new file mode 100644 index 00000000..6a1f45d5 --- /dev/null +++ b/src/cljs/wish/views/campaign/events.cljs @@ -0,0 +1,54 @@ +(ns ^{:author "Daniel Leong" + :doc "Campaign-specific events"} + wish.views.campaign.events + (:require-macros [wish.util.log :as log :refer [log]]) + (:require [clojure.string :as str] + [re-frame.core :refer [dispatch reg-event-db reg-event-fx + path + inject-cofx trim-v]] + [day8.re-frame.tracing :refer-macros [fn-traced defn-traced]] + [vimsical.re-frame.cofx.inject :as inject] + [wish.sheets.util :refer [update-sheet-path]])) + +;; can also be passed nil to leave a campaign +(reg-event-fx + ::join-campaign + [trim-v (inject-cofx ::inject/sub [:active-sheet-id])] + (fn [{:keys [active-sheet-id] :as cofx} [campaign-id ?campaign-name]] + (if campaign-id + (let [info {:id campaign-id + :name ?campaign-name}] + (log/info "Joining " campaign-id) + + (-> cofx + (update-sheet-path [] assoc :campaign info) + + ; raise a notifier + (assoc :dispatch [:notify! {:duration :short + :content (str "Joined " + (or (:name info) + "the campaign") + " successfully!")}]))) + + (do + (log/info "Leaving campaign") + + (-> cofx + (update-sheet-path [] dissoc :campaign) + + ; raise a notifier + (assoc :dispatch [:notify! {:duration :short + :content "Left the campaign successfully."}])))))) + +(reg-event-fx + ::add-player + [trim-v] + (fn-traced [cofx [character-id]] + (update-sheet-path cofx [:players] conj character-id))) + +(reg-event-fx + ::remove-player + [trim-v] + (fn-traced [cofx [character-id]] + (update-sheet-path cofx [:players] disj character-id))) + diff --git a/src/cljs/wish/views/campaign/hp_bar.cljs b/src/cljs/wish/views/campaign/hp_bar.cljs new file mode 100644 index 00000000..a2f4b127 --- /dev/null +++ b/src/cljs/wish/views/campaign/hp_bar.cljs @@ -0,0 +1,56 @@ +(ns ^{:author "Daniel Leong" + :doc "hp-bar"} + wish.views.campaign.hp-bar + (:require [garden.color :as color])) + +(def ^:private health-colors + ["#cc4433" + "#cc6633" + "#aaaa33" + "#00cc33"]) + +(defn- css% [fraction] + (str (int (* 100 fraction)) "%")) + +; NOTE: garden's weighted-mix fn is borked, so let's +; make the real deal: +(defn weighted-mix + "Returns a hex color that is `weight` % from + color1 to color2; weight must be a decimal + in the range `[0, 1]`. + A weight of 0 means color1 is returned, while + a weight of 1.0 means color2 is returned." + [color1 color2 weight] + (letfn [(mix [a b] + (int (+ (* a (- 1.0 weight)) + (* b weight))))] + (let [rgb1 (color/hex->rgb color1) + rgb2 (color/hex->rgb color2)] + (->> [:red :green :blue] + (reduce + (fn [m channel] + (assoc m channel (mix (channel rgb1) + (channel rgb2)))) + {}) + (color/rgb->hex))))) + +(defn- perc->color [fraction] + (let [fractional-index (* (dec (count health-colors)) + fraction) + int-val (int fractional-index) + weight (- fractional-index int-val)] + (if (<= weight 0.0001) + (nth health-colors int-val) + + (let [start (nth health-colors int-val) + end (nth health-colors (inc int-val))] + (weighted-mix start end weight))))) + +(defn hp-bar [hp max-hp] + (let [perc (/ hp max-hp)] + [:div.hp-bar + [:div.bar {:style {:background-color (perc->color perc) + :width (css% perc)}}] + [:div.label + hp " / " max-hp] + ])) diff --git a/src/cljs/wish/views/campaign/join.cljs b/src/cljs/wish/views/campaign/join.cljs new file mode 100644 index 00000000..7aae5cb0 --- /dev/null +++ b/src/cljs/wish/views/campaign/join.cljs @@ -0,0 +1,29 @@ +(ns ^{:author "Daniel Leong" + :doc "Join a campaign as a player"} + wish.views.campaign.join + (:require [wish.util :refer [evt click>evts]] + [wish.util.nav :refer [sheet-url]] + [wish.views.campaign.events :as events])) + +(defn page [[campaign-id sheet-id ?campaign-name]] + [:div + [:h3 "You've been invited to " (or ?campaign-name + "a campaign")] + + [:div.explanation + "This does not automatically grant the DM permission to modify your sheet, + nor does it grant them permission to read it. You will need to use the + sharing settings at the top of your sheet to grant whatever permissions + you feel necessary. They must be able to at least read your sheet for + there to be any benefit in joining the campaign, however!"] + + [:div + [:h4 "Would you like to join?"] + + [:div.button {:on-click (click>evts + [::events/join-campaign campaign-id ?campaign-name] + [:nav/replace! (sheet-url sheet-id)])} + "Yes! Join " (or ?campaign-name + "the campaign")] + ] + ]) diff --git a/src/cljs/wish/views/campaign/subs.cljs b/src/cljs/wish/views/campaign/subs.cljs new file mode 100644 index 00000000..63ed452f --- /dev/null +++ b/src/cljs/wish/views/campaign/subs.cljs @@ -0,0 +1,28 @@ +(ns ^{:author "Daniel Leong" + :doc "Campaign-specific subs"} + wish.views.campaign.subs + (:require [clojure.string :as str] + [re-frame.core :refer [reg-sub subscribe]])) + +(reg-sub + ::add-char-candidates + :<- [:meta/players] + :<- [:known-sheets] + (fn [[current-members sheets]] + (let [by-mine (->> sheets + (remove (comp current-members :id)) + (group-by :mine?))] + (concat (get by-mine false) + (get by-mine true))))) + +(reg-sub + ::campaign-members + :<- [:meta/players] + :<- [:sheets] + (fn [[char-sheet-ids sheets] qv] + (->> char-sheet-ids + (map (fn [id] + (or (assoc (get sheets id) + :id id) + {:id id}))) + (sort-by :name)))) diff --git a/src/cljs/wish/views/campaign_browser.cljs b/src/cljs/wish/views/campaign_browser.cljs new file mode 100644 index 00000000..88f660a0 --- /dev/null +++ b/src/cljs/wish/views/campaign_browser.cljs @@ -0,0 +1,29 @@ +(ns ^{:author "Daniel Leong" + :doc "campaign-browser"} + wish.views.campaign-browser + (:require [reagent.core :as r] + [reagent-forms.core :refer [bind-fields]] + [wish.util :refer [>evt + [:div + [:p "Pick a name (you can change it later)"] + [:input {:field :text + :id :name}]] + + [:div + [:p "Pick a sheet type (you " [:i "can't"] " can't change this one!)"] + (into + [:select {:field :list + :id :sheet} + [:option {:key :-none} "—Pick a sheet type—"]] + + (for [[sheet-id info] sheets/sheets] + ^{:key sheet-id} + [:option {:key sheet-id} + (:name info)]))]] + + form-data] + + [:div + [:p "Where do you want to store it?"] + + (for [[provider-id state] (evt evt]])) + +(defn notifier + [& {:keys [ignore-event + content + action-label + action-event]}] + [:div.notifier + (when ignore-event + [:div.ignore + [link>evt {:class "link" + :> ignore-event} + (icon :close)]]) + + [:div.content content] + + (when action-event + [:div.action + [link>evt action-event + action-label]])]) + +(defn update-notifier [] + (when (evt click>evt]] [wish.util.nav :refer [sheet-url]] [wish.providers :as providers] - [wish.views.widgets :refer [link save-state] :refer-macros [icon]])) + [wish.views.widgets :refer [link link>evt save-state] :refer-macros [icon]])) (defn- find-section [candidates target-id] @@ -88,6 +88,40 @@ [link {:href (sheet-url sheet-id)} "Let's play!"])]]])) +(defn campaign-manager [] + (when-let [campaign-info ( + [:h3 "Campaign"] + + [:div.group + ( + [link>evt [:share-sheet! sheet-id] + "Click here"] + " to do this now."]) ] + + [:div.group + [:input {:type 'button + :on-click (click>evt [:join-campaign]) + :value "I understand; leave the campaign"}]]] + + [:div.group + [link>evt {:on-click (fn-click + (swap! wants-to-leave? not))} + "Click here if you want to leave this campaign."] + ])]))) + (defn data-source-manager [] (let [original-ids (evt [:query-data-sources]) (let [sheet-id ( [:h3 "Data Sources"] [:div (if-let [sources (evt evt]])) - -(defn update-notifier [] - (when (evt {:class "link" - :> [:ignore-latest-update]} - (icon :close)]] - [:div.content "New version of WISH available!"] - [:div.update - [link>evt [:update-app] - "Update"]]])) diff --git a/src/cljs/wish/views/widgets.cljs b/src/cljs/wish/views/widgets.cljs index 00823911..0b286691 100644 --- a/src/cljs/wish/views/widgets.cljs +++ b/src/cljs/wish/views/widgets.cljs @@ -54,7 +54,7 @@ (vector? evt-or-opts) (assoc base :on-click (click>evt evt-or-opts)) ; map with :on-click, the easy case - (:on-click evt-or-opts) evt-or-opts + (:on-click evt-or-opts) (merge base evt-or-opts) ; fancy case; merge in all the provided opts, in case they ; provided, eg :class