summaryrefslogtreecommitdiff
path: root/sw-ui/src/main/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'sw-ui/src/main/kotlin')
-rw-r--r--sw-ui/src/main/kotlin/blueprintjs.kt525
-rw-r--r--sw-ui/src/main/kotlin/blueprintjsHelpers.kt137
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt49
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt22
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt124
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt43
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt178
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt174
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt164
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt76
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt10
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt133
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt64
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt21
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt19
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt66
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt121
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt57
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt106
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt26
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt23
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt84
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt29
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt39
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt50
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt36
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt42
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt54
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt137
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt32
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt15
-rw-r--r--sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt16
34 files changed, 2744 insertions, 0 deletions
diff --git a/sw-ui/src/main/kotlin/blueprintjs.kt b/sw-ui/src/main/kotlin/blueprintjs.kt
new file mode 100644
index 00000000..13de8744
--- /dev/null
+++ b/sw-ui/src/main/kotlin/blueprintjs.kt
@@ -0,0 +1,525 @@
+@file:JsModule("@blueprintjs/core")
+package com.palantir.blueprintjs
+
+import org.w3c.dom.HTMLElement
+import org.w3c.dom.HTMLInputElement
+import org.w3c.dom.events.Event
+import org.w3c.dom.events.MouseEvent
+import react.*
+
+/**
+ * The four basic intents.
+ */
+// export declare const Intent: {
+// NONE: "none";
+// PRIMARY: "primary";
+// SUCCESS: "success";
+// WARNING: "warning";
+// DANGER: "danger";
+//};
+//export declare type Intent = typeof Intent[keyof typeof Intent];
+external enum class Intent {
+ NONE,
+ PRIMARY,
+ SUCCESS,
+ WARNING,
+ DANGER
+}
+
+/** Alignment along the horizontal axis. */
+//export declare const Alignment: {
+// CENTER: "center";
+// LEFT: "left";
+// RIGHT: "right";
+//};
+//export declare type Alignment = typeof Alignment[keyof typeof Alignment];
+external enum class Alignment {
+ CENTER,
+ LEFT,
+ RIGHT
+}
+
+/**
+ * A shared base interface for all Blueprint component props.
+ */
+external interface IProps : RProps {
+ /** A space-delimited list of class names to pass along to a child element. */
+ var className: String?
+}
+external interface IIntentProps {
+ /** Visual intent color to apply to element. */
+ var intent: Intent?
+}
+/**
+ * Interface for a clickable action, such as a button or menu item.
+ * These props can be spready directly to a `<Button>` or `<MenuItem>` element.
+ */
+external interface IActionProps : IIntentProps, IProps {
+ /** Whether this action is non-interactive. */
+ var disabled: Boolean?
+ /** Name of a Blueprint UI icon (or an icon element) to render before the text. */
+ var icon: IconName?
+ /** Click event handler. */
+ var onClick: ((event: MouseEvent) -> Unit)?
+ /** Action text. Can be any single React renderable. */
+ var text: String?
+}
+/** Interface for a link, with support for customizing target window. */
+external interface ILinkProps {
+ /** Link URL. */
+ var href: String?
+ /** Link target attribute. Use `"_blank"` to open in a new window. */
+ var target: String?
+}
+/** Interface for a controlled input. */
+external interface IControlledProps {
+ /** Initial value of the input, for uncontrolled usage. */
+ var defaultValue: String?
+ /** Change event handler. Use `event.target.value` for new value. */
+ var onChange: ((Event) -> Unit)?
+ /** Form value of the input, for controlled usage. */
+ var value: String?
+}
+/**
+ * An interface for an option in a list, such as in a `<select>` or `RadioGroup`.
+ * These props can be spread directly to an `<option>` or `<Radio>` element.
+ */
+external interface IOptionProps : IProps {
+ /** Whether this option is non-interactive. */
+ var disabled: Boolean?
+ /** Label text for this option. If omitted, `value` is used as the label. */
+ var label: String?
+ /** Value of this option. */
+ var value: Any? // String | Number
+}
+
+external interface IIconProps : IIntentProps, IProps {
+ /**
+ * Color of icon. This is used as the `fill` attribute on the `<svg>` image
+ * so it will override any CSS `color` property, including that set by
+ * `intent`. If this prop is omitted, icon color is inherited from
+ * surrounding text.
+ */
+ var color: String?
+ /**
+ * String for the `title` attribute on the rendered element, which will appear
+ * on hover as a native browser tooltip.
+ */
+ var htmlTitle: String?
+ /**
+ * Name of a Blueprint UI icon, or an icon element, to render. This prop is
+ * required because it determines the content of the component, but it can
+ * be explicitly set to falsy values to render nothing.
+ *
+ * - If `null` or `undefined` or `false`, this component will render
+ * nothing.
+ * - If given an `IconName` (a string literal union of all icon names), that
+ * icon will be rendered as an `<svg>` with `<path>` tags. Unknown strings
+ * will render a blank icon to occupy space.
+ * - If given a `JSX.Element`, that element will be rendered and _all other
+ * props on this component are ignored._ This type is supported to
+ * simplify icon support in other Blueprint components. As a consumer, you
+ * should avoid using `<Icon icon={<Element />}` directly; simply render
+ * `<Element />` instead.
+ */
+ var icon: IconName
+ /**
+ * Size of the icon, in pixels. Blueprint contains 16px and 20px SVG icon
+ * images, and chooses the appropriate resolution based on this prop.
+ * @default Icon.SIZE_STANDARD = 16
+ */
+ var iconSize: Int?
+ /** CSS style properties. */
+ //var style: CSSProperties? // TODO
+ /**
+ * HTML tag to use for the rendered element.
+ * @default "span"
+ */
+ var tagName: String? // keyof JSX.IntrinsicElements
+ /**
+ * Description string. This string does not appear in normal browsers, but
+ * it increases accessibility. For instance, screen readers will use it for
+ * aural feedback. By default, this is set to the icon's name. Pass an
+ * explicit falsy value to disable.
+ */
+ var title: String?
+}
+
+external class Icon : PureComponent<IIconProps, RState> {
+
+ override fun render(): ReactElement?
+
+ companion object {
+ val SIZE_STANDARD: Int = definedExternally
+ val SIZE_LARGE: Int = definedExternally
+ }
+}
+
+external interface IButtonProps : IActionProps {
+ // artificially added to allow title on button (should probably be on more general props)
+ var title: String?
+ /**
+ * If set to `true`, the button will display in an active state.
+ * This is equivalent to setting `className={Classes.ACTIVE}`.
+ * @default false
+ */
+ var active: Boolean?
+ /**
+ * Text alignment within button. By default, icons and text will be centered
+ * within the button. Passing `"left"` or `"right"` will align the button
+ * text to that side and push `icon` and `rightIcon` to either edge. Passing
+ * `"center"` will center the text and icons together.
+ * @default Alignment.CENTER
+ */
+ var alignText: Alignment?
+ /** A ref handler that receives the native HTML element backing this component. */
+ var elementRef: ((ref: HTMLElement?) -> Any)?
+ /** Whether this button should expand to fill its container. */
+ var fill: Boolean?
+ /** Whether this button should use large styles. */
+ var large: Boolean?
+ /**
+ * If set to `true`, the button will display a centered loading spinner instead of its contents.
+ * The width of the button is not affected by the value of this prop.
+ * @default false
+ */
+ var loading: Boolean?
+ /** Whether this button should use minimal styles. */
+ var minimal: Boolean?
+ /** Whether this button should use outlined styles. */
+ var outlined: Boolean?
+ /** Name of a Blueprint UI icon (or an icon element) to render after the text. */
+ var rightIcon: IconName?
+ /** Whether this button should use small styles. */
+ var small: Boolean?
+ /**
+ * HTML `type` attribute of button. Accepted values are `"button"`, `"submit"`, and `"reset"`.
+ * Note that this prop has no effect on `AnchorButton`; it only affects `Button`.
+ * @default "button"
+ */
+ var type: String? //"submit" | "reset" | "button";
+}
+
+external interface IButtonState : RState {
+ var isActive: Boolean
+}
+
+abstract external class AbstractButton : PureComponent<IButtonProps, IButtonState> {
+}
+
+external class Button : AbstractButton {
+ override fun render(): ReactElement
+}
+external class AnchorButton : AbstractButton {
+ override fun render(): ReactElement
+}
+
+external interface IButtonGroupProps : IProps {
+ /**
+ * Text alignment within button. By default, icons and text will be centered
+ * within the button. Passing `"left"` or `"right"` will align the button
+ * text to that side and push `icon` and `rightIcon` to either edge. Passing
+ * `"center"` will center the text and icons together.
+ */
+ var alignText: Alignment?
+
+ /**
+ * Whether the button group should take up the full width of its container.
+ * @default false
+ */
+ var fill: Boolean?
+
+ /**
+ * Whether the child buttons should appear with minimal styling.
+ * @default false
+ */
+ var minimal: Boolean?
+
+ /**
+ * Whether the child buttons should appear with large styling.
+ * @default false
+ */
+ var large: Boolean?
+
+ /**
+ * Whether the button group should appear with vertical styling.
+ * @default false
+ */
+ var vertical: Boolean?
+}
+
+external class ButtonGroup : PureComponent<IButtonGroupProps, RState> {
+ override fun render(): ReactElement?
+}
+
+external interface IInputGroupProps : IControlledProps, IIntentProps, IProps {
+ /**
+ * Whether the input is non-interactive.
+ * Note that `rightElement` must be disabled separately; this prop will not affect it.
+ * @default false
+ */
+ var disabled: Boolean?
+ /**
+ * Whether the component should take up the full width of its container.
+ */
+ var fill: Boolean?
+ /** Ref handler that receives HTML `<input>` element backing this component. */
+ var inputRef: ((ref: HTMLInputElement?) -> Any)?;
+ /**
+ * Name of a Blueprint UI icon (or an icon element) to render on the left side of the input group,
+ * before the user's cursor.
+ */
+ var leftIcon: IconName?
+ /** Whether this input should use large styles. */
+ var large: Boolean?
+ /** Whether this input should use small styles. */
+ var small: Boolean?
+ /** Placeholder text in the absence of any value. */
+ var placeholder: String?
+ /**
+ * Element to render on right side of input.
+ * For best results, use a minimal button, tag, or small spinner.
+ */
+ var rightElement: ReactElement?
+ /** Whether the input (and any buttons) should appear with rounded caps. */
+ var round: Boolean?
+ /**
+ * HTML `input` type attribute.
+ * @default "text"
+ */
+ var type: String?
+}
+
+external interface IInputGroupState : RState {
+ var rightElementWidth: Int
+}
+
+external class InputGroup : PureComponent<IInputGroupProps, IInputGroupState> {
+ override fun render(): ReactElement
+}
+
+external interface ITagProps : IProps, IIntentProps {
+ /**
+ * Whether the tag should appear in an active state.
+ * @default false
+ */
+ var active: Boolean?
+ /**
+ * Whether the tag should take up the full width of its container.
+ * @default false
+ */
+ var fill: Boolean?
+ /** Name of a Blueprint UI icon (or an icon element) to render before the children. */
+ var icon: IconName?
+ /**
+ * Whether the tag should visually respond to user interactions. If set
+ * to `true`, hovering over the tag will change its color and mouse cursor.
+ *
+ * Recommended when `onClick` is also defined.
+ *
+ * @default false
+ */
+ var interactive: Boolean?
+ /**
+ * Whether this tag should use large styles.
+ * @default false
+ */
+ var large: Boolean?
+ /**
+ * Whether this tag should use minimal styles.
+ * @default false
+ */
+ var minimal: Boolean?
+ /**
+ * Whether tag content should be allowed to occupy multiple lines.
+ * If false, a single line of text will be truncated with an ellipsis if
+ * it overflows. Note that icons will be vertically centered relative to
+ * multiline text.
+ * @default false
+ */
+ var multiline: Boolean?
+ /**
+ * Callback invoked when the tag is clicked.
+ * Recommended when `interactive` is `true`.
+ */
+ var onClick: ((e: MouseEvent) -> Unit)?;
+ /**
+ * Click handler for remove button.
+ * The remove button will only be rendered if this prop is defined.
+ */
+ var onRemove: ((e: MouseEvent, tagProps: ITagProps) -> Unit)?
+ /** Name of a Blueprint UI icon (or an icon element) to render after the children. */
+ var rightIcon: IconName?
+ /**
+ * Whether this tag should have rounded ends.
+ * @default false
+ */
+ var round: Boolean?
+}
+
+external class Tag : PureComponent<ITagProps, RState> {
+ override fun render(): ReactElement
+}
+
+external interface INonIdealStateProps : IProps {
+ /** An action to resolve the non-ideal state which appears after `description`. */
+ var action: ReactElement?
+
+ /**
+ * Advanced usage: React `children` will appear last (after `action`).
+ * Avoid passing raw strings as they will not receive margins and disrupt the layout flow.
+ */
+ var children: ReactElement?
+
+ /**
+ * A longer description of the non-ideal state.
+ * A string or number value will be wrapped in a `<div>` to preserve margins.
+ */
+ var description: ReactElement?
+
+ /** The name of a Blueprint icon or a JSX Element (such as `<Spinner/>`) to render above the title. */
+ var icon: IconName?
+
+ /** The title of the non-ideal state. */
+ var title: ReactElement?
+}
+
+external class NonIdealState : PureComponent<INonIdealStateProps, RState> {
+ override fun render(): ReactElement?
+}
+
+external class Classes {
+ companion object {
+ val HTML_TABLE: String = definedExternally
+ }
+}
+
+external interface IOverlayableProps : IOverlayLifecycleProps {
+ /**
+ * Whether the overlay should acquire application focus when it first opens.
+ * @default true
+ */
+ var autoFocus: Boolean?
+ /**
+ * Whether pressing the `esc` key should invoke `onClose`.
+ * @default true
+ */
+ var canEscapeKeyClose: Boolean?
+ /**
+ * Whether the overlay should prevent focus from leaving itself. That is, if the user attempts
+ * to focus an element outside the overlay and this prop is enabled, then the overlay will
+ * immediately bring focus back to itself. If you are nesting overlay components, either disable
+ * this prop on the "outermost" overlays or mark the nested ones `usePortal={false}`.
+ * @default true
+ */
+ var enforceFocus: Boolean?
+ /**
+ * If `true` and `usePortal={true}`, the `Portal` containing the children is created and attached
+ * to the DOM when the overlay is opened for the first time; otherwise this happens when the
+ * component mounts. Lazy mounting provides noticeable performance improvements if you have lots
+ * of overlays at once, such as on each row of a table.
+ * @default true
+ */
+ var lazy: Boolean?
+ /**
+ * Indicates how long (in milliseconds) the overlay's enter/leave transition takes.
+ * This is used by React `CSSTransition` to know when a transition completes and must match
+ * the duration of the animation in CSS. Only set this prop if you override Blueprint's default
+ * transitions with new transitions of a different length.
+ * @default 300
+ */
+ var transitionDuration: Int?
+ /**
+ * Whether the overlay should be wrapped in a `Portal`, which renders its contents in a new
+ * element attached to `portalContainer` prop.
+ *
+ * This prop essentially determines which element is covered by the backdrop: if `false`,
+ * then only its parent is covered; otherwise, the entire page is covered (because the parent
+ * of the `Portal` is the `<body>` itself).
+ *
+ * Set this prop to `false` on nested overlays (such as `Dialog` or `Popover`) to ensure that they
+ * are rendered above their parents.
+ * @default true
+ */
+ var usePortal: Boolean?
+ /**
+ * Space-delimited string of class names applied to the `Portal` element if
+ * `usePortal={true}`.
+ */
+ var portalClassName: String?
+ /**
+ * The container element into which the overlay renders its contents, when `usePortal` is `true`.
+ * This prop is ignored if `usePortal` is `false`.
+ * @default document.body
+ */
+ var portalContainer: HTMLElement?
+ /**
+ * A callback that is invoked when user interaction causes the overlay to close, such as
+ * clicking on the overlay or pressing the `esc` key (if enabled).
+ *
+ * Receives the event from the user's interaction, if there was an event (generally either a
+ * mouse or key event). Note that, since this component is controlled by the `isOpen` prop, it
+ * will not actually close itself until that prop becomes `false`.
+ */
+ var onClose: ((Event) -> Unit)?
+}
+external interface IOverlayLifecycleProps {
+ /**
+ * Lifecycle method invoked just before the CSS _close_ transition begins on
+ * a child. Receives the DOM element of the child being closed.
+ */
+ var onClosing: ((node: HTMLElement) -> Unit)?
+ /**
+ * Lifecycle method invoked just after the CSS _close_ transition ends but
+ * before the child has been removed from the DOM. Receives the DOM element
+ * of the child being closed.
+ */
+ var onClosed: ((node: HTMLElement) -> Unit)?
+ /**
+ * Lifecycle method invoked just after mounting the child in the DOM but
+ * just before the CSS _open_ transition begins. Receives the DOM element of
+ * the child being opened.
+ */
+ var onOpening: ((node: HTMLElement) -> Unit)?
+ /**
+ * Lifecycle method invoked just after the CSS _open_ transition ends.
+ * Receives the DOM element of the child being opened.
+ */
+ var onOpened: ((node: HTMLElement) -> Unit)?
+}
+external interface IBackdropProps {
+ /** CSS class names to apply to backdrop element. */
+ var backdropClassName: String?
+ /** HTML props for the backdrop element. */
+ var backdropProps: RProps? //React.HTMLProps<HTMLDivElement>?
+ /**
+ * Whether clicking outside the overlay element (either on backdrop when present or on document)
+ * should invoke `onClose`.
+ * @default true
+ */
+ var canOutsideClickClose: Boolean?
+ /**
+ * Whether a container-spanning backdrop element should be rendered behind the contents.
+ * @default true
+ */
+ var hasBackdrop: Boolean?
+}
+external interface IOverlayProps : IOverlayableProps, IBackdropProps, IProps {
+ /**
+ * Toggles the visibility of the overlay and its children.
+ * This prop is required because the component is controlled.
+ */
+ var isOpen: Boolean
+ /**
+ * Name of the transition for internal `CSSTransition`.
+ * Providing your own name here will require defining new CSS transition properties.
+ * @default Classes.OVERLAY
+ */
+ var transitionName: String?
+}
+external interface IOverlayState : RState {
+ var hasEverOpened: Boolean?
+}
+external class Overlay : PureComponent<IOverlayProps, IOverlayState> {
+ override fun render(): ReactElement
+} \ No newline at end of file
diff --git a/sw-ui/src/main/kotlin/blueprintjsHelpers.kt b/sw-ui/src/main/kotlin/blueprintjsHelpers.kt
new file mode 100644
index 00000000..da6b6914
--- /dev/null
+++ b/sw-ui/src/main/kotlin/blueprintjsHelpers.kt
@@ -0,0 +1,137 @@
+package com.palantir.blueprintjs
+
+import org.w3c.dom.events.Event
+import org.w3c.dom.events.MouseEvent
+import react.RBuilder
+import react.RHandler
+import react.ReactElement
+
+typealias IconName = String
+
+fun RBuilder.bpIcon(
+ name: IconName,
+ size: Int = Icon.SIZE_STANDARD,
+ intent: Intent = Intent.NONE,
+ title: String? = null,
+ alt: String? = null,
+ className: String? = null,
+ block: RHandler<IIconProps> = {}
+): ReactElement = child(Icon::class) {
+ attrs {
+ this.icon = name
+ this.iconSize = size
+ this.htmlTitle = title
+ this.intent = intent
+ this.title = alt
+ this.className = className
+ }
+ block()
+}
+
+fun RBuilder.bpButton(
+ minimal: Boolean = false,
+ small: Boolean = false,
+ large: Boolean = false,
+ disabled: Boolean = false,
+ title: String? = null,
+ icon: IconName? = null,
+ rightIcon: IconName? = null,
+ intent: Intent = Intent.NONE,
+ onClick: ((event: MouseEvent) -> Unit)? = {},
+ block: RHandler<IButtonProps> = {}
+): ReactElement = child(Button::class) {
+ attrs {
+ this.title = title
+ this.minimal = minimal
+ this.small = small
+ this.large = large
+ this.disabled = disabled
+ this.icon = icon
+ this.rightIcon = rightIcon
+ this.intent = intent
+ this.onClick = onClick
+ }
+ block()
+}
+
+fun RBuilder.bpButtonGroup(
+ large: Boolean = false,
+ minimal: Boolean = false,
+ block: RHandler<IButtonGroupProps> = {}
+): ReactElement = child(ButtonGroup::class) {
+ attrs {
+ this.large = large
+ this.minimal = minimal
+ }
+ block()
+}
+
+fun RBuilder.bpInputGroup(
+ large: Boolean = false,
+ placeholder: String = "",
+ rightElement: ReactElement? = null,
+ onChange: (Event) -> Unit
+): ReactElement = child(InputGroup::class) {
+ attrs {
+ this.large = large
+ this.placeholder = placeholder
+ this.rightElement = rightElement
+ this.onChange = onChange
+ }
+}
+
+fun RBuilder.bpTag(
+ intent: Intent? = null,
+ minimal: Boolean? = null,
+ active: Boolean? = null,
+ block: RHandler<ITagProps> = {}
+): ReactElement = child(Tag::class) {
+ attrs {
+ this.intent = intent
+ this.minimal = minimal
+ this.active = active
+ }
+ block()
+}
+
+fun RBuilder.bpNonIdealState(
+ icon: IconName? = null,
+ title: ReactElement? = null,
+ description: ReactElement? = null,
+ action: ReactElement? = null,
+ children: ReactElement? = null,
+ block: RHandler<INonIdealStateProps> = {}
+): ReactElement = child(NonIdealState::class) {
+ attrs {
+ this.icon = icon
+ this.title = title
+ this.description = description
+ this.action = action
+ this.children = children
+ }
+ block()
+}
+
+fun RBuilder.bpOverlay(
+ isOpen: Boolean,
+ autoFocus: Boolean = true,
+ enforceFocus: Boolean = true,
+ usePortal: Boolean = true,
+ hasBackdrop: Boolean = true,
+ canEscapeKeyClose: Boolean = true,
+ canOutsideClickClose: Boolean = true,
+ onClose: () -> Unit = {},
+ block: RHandler<IOverlayProps> = {}
+): ReactElement = child(Overlay::class) {
+ attrs {
+ this.isOpen = isOpen
+ this.autoFocus = autoFocus
+ this.enforceFocus = enforceFocus
+ this.usePortal = usePortal
+ this.hasBackdrop = hasBackdrop
+ this.canEscapeKeyClose = canEscapeKeyClose
+ this.canOutsideClickClose = canOutsideClickClose
+ this.onClose = { onClose() }
+ }
+ block()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
new file mode 100644
index 00000000..8b38e010
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/SevenWondersUi.kt
@@ -0,0 +1,49 @@
+package org.luxons.sevenwonders.ui
+
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.ui.components.application
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.redux.configureStore
+import org.luxons.sevenwonders.ui.redux.sagas.SagaManager
+import org.luxons.sevenwonders.ui.redux.sagas.rootSaga
+import org.w3c.dom.Element
+import react.dom.*
+import react.redux.provider
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import kotlin.browser.document
+import kotlin.browser.window
+
+fun main() {
+ window.onload = {
+ val rootElement = document.getElementById("root")
+ if (rootElement != null) {
+ initializeAndRender(rootElement)
+ } else {
+ console.error("Element with ID 'root' was not found, cannot bootstrap react app")
+ }
+ }
+}
+
+private fun initializeAndRender(rootElement: Element) {
+ val store = initRedux()
+
+ render(rootElement) {
+ provider(store) {
+ application()
+ }
+ }
+}
+
+private fun initRedux(): Store<SwState, RAction, WrapperAction> {
+ val sagaManager = SagaManager<SwState, RAction, WrapperAction>()
+ val store = configureStore(sagaManager = sagaManager)
+ GlobalScope.launch {
+ sagaManager.launchSaga(this) {
+ rootSaga()
+ }
+ }
+ return store
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
new file mode 100644
index 00000000..b1244b5c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/Application.kt
@@ -0,0 +1,22 @@
+package org.luxons.sevenwonders.ui.components
+
+import org.luxons.sevenwonders.ui.components.game.gameScene
+import org.luxons.sevenwonders.ui.components.gameBrowser.gameBrowser
+import org.luxons.sevenwonders.ui.components.home.home
+import org.luxons.sevenwonders.ui.components.lobby.lobby
+import org.luxons.sevenwonders.ui.router.Route
+import react.RBuilder
+import react.router.dom.hashRouter
+import react.router.dom.redirect
+import react.router.dom.route
+import react.router.dom.switch
+
+fun RBuilder.application() = hashRouter {
+ switch {
+ route(Route.GAME_BROWSER.path) { gameBrowser() }
+ route(Route.GAME.path) { gameScene() }
+ route(Route.LOBBY.path) { lobby() }
+ route(Route.HOME.path, exact = true) { home() }
+ redirect(from = "*", to = "/")
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
new file mode 100644
index 00000000..f5b16248
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/GlobalStyles.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.components
+
+import kotlinx.css.Overflow
+import kotlinx.css.Position
+import kotlinx.css.bottom
+import kotlinx.css.left
+import kotlinx.css.overflow
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.right
+import kotlinx.css.top
+import styled.StyleSheet
+
+object GlobalStyles : StyleSheet("GlobalStyles", isStatic = true) {
+
+ val fullscreen by css {
+ position = Position.fixed
+ top = 0.px
+ left = 0.px
+ bottom = 0.px
+ right = 0.px
+ overflow = Overflow.hidden
+ }
+
+ val fixedCenter by css {
+ position = Position.fixed
+ left = 50.pct
+ top = 50.pct
+ transform {
+ translate((-50).pct, (-50).pct)
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
new file mode 100644
index 00000000..dd67757a
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Board.kt
@@ -0,0 +1,124 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.Position
+import kotlinx.css.TextAlign
+import kotlinx.css.display
+import kotlinx.css.height
+import kotlinx.css.margin
+import kotlinx.css.maxHeight
+import kotlinx.css.maxWidth
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.rem
+import kotlinx.css.textAlign
+import kotlinx.css.vh
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.boards.Board
+import org.luxons.sevenwonders.model.cards.TableCard
+import org.luxons.sevenwonders.model.wonders.ApiWonder
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+import styled.styledImg
+
+// card offsets in % of their size when displayed in columns
+private const val xOffset = 20
+private const val yOffset = 21
+
+fun RBuilder.boardComponent(board: Board) {
+ styledDiv {
+ css {
+ width = 100.vw
+ }
+ tableCards(cardColumns = board.playedCards)
+ wonderComponent(wonder = board.wonder)
+ }
+}
+
+private fun RBuilder.tableCards(cardColumns: List<List<TableCard>>) {
+ styledDiv {
+ css {
+ display = Display.flex
+ height = 40.vh
+ width = 100.vw
+ }
+ cardColumns.forEach { cards ->
+ tableCardColumn(cards = cards) {
+ attrs {
+ key = cards.first().color.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tableCardColumn(cards: List<TableCard>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ height = 40.vh
+ width = 15.vw
+ margin = "auto"
+ position = Position.relative
+ }
+ block()
+ cards.forEachIndexed { index, card ->
+ tableCard(card = card, indexInColumn = index) {
+ attrs { key = card.name }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tableCard(card: TableCard, indexInColumn: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ position = Position.absolute
+ zIndex = indexInColumn
+ transform {
+ translate(
+ tx = (indexInColumn * xOffset).pct,
+ ty = (indexInColumn * yOffset).pct
+ )
+ }
+ }
+ block()
+ val highlightColor = if (card.playedDuringLastMove) Color.gold else null
+ cardImage(card = card, highlightColor = highlightColor) {
+ css {
+ maxWidth = 10.vw
+ maxHeight = 25.vh
+ }
+ }
+ }
+}
+
+private fun RBuilder.wonderComponent(wonder: ApiWonder) {
+ styledDiv {
+ css {
+ width = 100.vw
+ textAlign = TextAlign.center
+ }
+ styledImg(src="/images/wonders/${wonder.image}") {
+ css {
+ declarations["border-radius"] = "0.5%/1.5%"
+ boxShadow(color = Color.black, offsetX = 0.2.rem, offsetY = 0.2.rem, blurRadius = 0.5.rem)
+ maxHeight = 30.vh
+ maxWidth = 95.vw
+ }
+ attrs {
+ this.title = wonder.name
+ this.alt = "Wonder ${wonder.name}"
+ }
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
new file mode 100644
index 00000000..38afe028
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/CardImage.kt
@@ -0,0 +1,43 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.borderRadius
+import kotlinx.css.pct
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.html.IMG
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.cards.Card
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledImg
+
+fun RBuilder.cardImage(card: Card, highlightColor: Color? = null, block: StyledDOMBuilder<IMG>.() -> Unit = {}) {
+ styledImg(src = "/images/cards/${card.image}") {
+ css {
+ borderRadius = 5.pct
+ boxShadow(offsetX = 2.px, offsetY = 2.px, blurRadius = 5.px, color = Color.black)
+ highlightStyle(highlightColor)
+ }
+ attrs {
+ title = card.name
+ alt = "Card ${card.name}"
+ }
+ block()
+ }
+}
+
+private fun CSSBuilder.highlightStyle(highlightColor: Color?) {
+ if (highlightColor != null) {
+ boxShadow(
+ offsetX = 0.px,
+ offsetY = 0.px,
+ blurRadius = 1.rem,
+ spreadRadius = 0.1.rem,
+ color = highlightColor
+ )
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
new file mode 100644
index 00000000..d54a0240
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/GameScene.kt
@@ -0,0 +1,178 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpButtonGroup
+import com.palantir.blueprintjs.bpOverlay
+import kotlinx.css.Position
+import kotlinx.css.background
+import kotlinx.css.backgroundSize
+import kotlinx.css.bottom
+import kotlinx.css.left
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.right
+import kotlinx.css.top
+import kotlinx.html.DIV
+import org.luxons.sevenwonders.model.Action
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.cards.HandCard
+import org.luxons.sevenwonders.ui.components.GlobalStyles
+import org.luxons.sevenwonders.ui.redux.GameState
+import org.luxons.sevenwonders.ui.redux.RequestPrepareMove
+import org.luxons.sevenwonders.ui.redux.RequestSayReady
+import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.ReactElement
+import react.dom.*
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+
+interface GameSceneStateProps: RProps {
+ var playerIsReady: Boolean
+ var players: List<PlayerDTO>
+ var gameState: GameState?
+ var preparedMove: PlayerMove?
+ var preparedCard: HandCard?
+}
+
+interface GameSceneDispatchProps: RProps {
+ var sayReady: () -> Unit
+ var prepareMove: (move: PlayerMove) -> Unit
+ var unprepareMove: () -> Unit
+}
+
+interface GameSceneProps : GameSceneStateProps, GameSceneDispatchProps
+
+private class GameScene(props: GameSceneProps) : RComponent<GameSceneProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ styledDiv {
+ css {
+ background = "url('images/background-papyrus3.jpg')"
+ backgroundSize = "cover"
+ +GlobalStyles.fullscreen
+ }
+ val turnInfo = props.gameState?.turnInfo
+ if (turnInfo == null) {
+ p { +"Error: no turn info data"}
+ } else {
+ turnInfoScene(turnInfo)
+ }
+ }
+ }
+
+ private fun RBuilder.sayReadyButton(): ReactElement {
+ val isReady = props.playerIsReady
+ val intent = if (isReady) Intent.SUCCESS else Intent.PRIMARY
+ return styledDiv {
+ css {
+ position = Position.absolute
+ bottom = 6.rem
+ left = 50.pct
+ transform { translate(tx = (-50).pct) }
+ }
+ bpButtonGroup {
+ bpButton(
+ large = true,
+ disabled = isReady,
+ intent = intent,
+ icon = if (isReady) "tick-circle" else "play",
+ onClick = { props.sayReady() }
+ ) {
+ +"READY"
+ }
+ // not really a button, but nice for style
+ bpButton(
+ large = true,
+ icon = "people",
+ disabled = isReady,
+ intent = intent
+ ) {
+ +"${props.players.count { it.isReady }}/${props.players.size}"
+ }
+ }
+ }
+ }
+
+ private fun RBuilder.turnInfoScene(turnInfo: PlayerTurnInfo) {
+ val board = turnInfo.table.boards[turnInfo.playerIndex]
+ div {
+ // TODO use blueprint's Callout component without header and primary intent
+ p { + turnInfo.message }
+ boardComponent(board = board)
+ val hand = turnInfo.hand
+ if (hand != null) {
+ handComponent(
+ cards = hand,
+ wonderUpgradable = turnInfo.wonderBuildability.isBuildable,
+ preparedMove = props.preparedMove,
+ prepareMove = props.prepareMove
+ )
+ }
+ val card = props.preparedCard
+ if (card != null) {
+ preparedMove(card)
+ }
+ if (turnInfo.action == Action.SAY_READY) {
+ sayReadyButton()
+ }
+ productionBar(gold = board.gold, production = board.production)
+ }
+ }
+
+ private fun RBuilder.preparedMove(card: HandCard) {
+ bpOverlay(isOpen = true, onClose = props.unprepareMove) {
+ styledDiv {
+ css { +GlobalStyles.fixedCenter }
+ cardImage(card)
+ styledDiv {
+ css {
+ position = Position.absolute
+ top = 0.px
+ right = 0.px
+ }
+ bpButton(
+ icon = "cross",
+ title = "Cancel prepared move",
+ small = true,
+ intent = Intent.DANGER,
+ onClick = { props.unprepareMove() }
+ )
+ }
+ }
+ }
+ }
+}
+
+fun RBuilder.gameScene() = gameScene {}
+
+private val gameScene: RClass<GameSceneProps> = connectStateAndDispatch<GameSceneStateProps, GameSceneDispatchProps,
+ GameSceneProps>(
+ clazz = GameScene::class,
+ mapDispatchToProps = { dispatch, _ ->
+ prepareMove = { move -> dispatch(RequestPrepareMove(move)) }
+ unprepareMove = { dispatch(RequestUnprepareMove()) }
+ sayReady = { dispatch(RequestSayReady()) }
+ },
+ mapStateToProps = { state, _ ->
+ playerIsReady = state.currentPlayer?.isReady == true
+ players = state.gameState?.players ?: emptyList()
+ gameState = state.gameState
+ preparedMove = state.gameState?.currentPreparedMove
+ preparedCard = state.gameState?.currentPreparedCard
+ }
+)
+
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
new file mode 100644
index 00000000..17ceffd2
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/Hand.kt
@@ -0,0 +1,174 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpButtonGroup
+import kotlinx.css.Align
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.GridColumn
+import kotlinx.css.GridRow
+import kotlinx.css.Position
+import kotlinx.css.alignItems
+import kotlinx.css.bottom
+import kotlinx.css.display
+import kotlinx.css.filter
+import kotlinx.css.gridColumn
+import kotlinx.css.gridRow
+import kotlinx.css.height
+import kotlinx.css.left
+import kotlinx.css.margin
+import kotlinx.css.maxHeight
+import kotlinx.css.maxWidth
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.properties.s
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.transition
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.vh
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import org.luxons.sevenwonders.model.MoveType
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.cards.HandCard
+import org.luxons.sevenwonders.model.cards.PreparedCard
+import org.luxons.sevenwonders.ui.components.game.cardImage
+import react.RBuilder
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+
+fun RBuilder.handComponent(
+ cards: List<HandCard>,
+ wonderUpgradable: Boolean,
+ preparedMove: PlayerMove?,
+ prepareMove: (PlayerMove) -> Unit
+) {
+ styledDiv {
+ css {
+ handStyle()
+ }
+ cards.filter { it.name != preparedMove?.cardName }.forEachIndexed { index, c ->
+ handCard(
+ card = c,
+ wonderUpgradable = wonderUpgradable,
+ prepareMove = prepareMove
+ ) {
+ attrs {
+ key = index.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.handCard(
+ card: HandCard,
+ wonderUpgradable: Boolean,
+ prepareMove: (PlayerMove) -> Unit,
+ block: StyledDOMBuilder<DIV>.() -> Unit
+) {
+ styledDiv {
+ css {
+ handCardStyle()
+ }
+ block()
+ cardImage(card) {
+ css {
+ handCardImgStyle(card.playability.isPlayable)
+ }
+ }
+ actionButtons(card, wonderUpgradable, prepareMove)
+ }
+}
+
+private fun RBuilder.actionButtons(card: HandCard, wonderUpgradable: Boolean, prepareMove: (PlayerMove) -> Unit) {
+ // class: action-buttons
+ styledDiv {
+ css {
+ alignItems = Align.flexEnd
+ display = Display.none
+ gridRow = GridRow("1")
+ gridColumn = GridColumn("1")
+
+ ancestorHover(".hand-card") {
+ display = Display.flex
+ }
+ }
+ bpButtonGroup {
+ bpButton(title = "PLAY",
+ large = true,
+ intent = Intent.SUCCESS,
+ icon = "play",
+ disabled = !card.playability.isPlayable,
+ onClick = { prepareMove(PlayerMove(MoveType.PLAY, card.name)) })
+ bpButton(title = "UPGRADE WONDER",
+ large = true,
+ intent = Intent.PRIMARY,
+ icon = "key-shift",
+ disabled = !wonderUpgradable,
+ onClick = { prepareMove(PlayerMove(MoveType.UPGRADE_WONDER, card.name)) })
+ bpButton(title = "DISCARD",
+ large = true,
+ intent = Intent.DANGER,
+ icon = "cross",
+ onClick = { prepareMove(PlayerMove(MoveType.DISCARD, card.name)) })
+ }
+ }
+}
+
+private fun CSSBuilder.handStyle() {
+ alignItems = Align.center
+ bottom = 0.px
+ display = Display.flex
+ height = 345.px
+ left = 50.pct
+ maxHeight = 25.vw
+ position = Position.absolute
+ transform {
+ translate(tx = (-50).pct, ty = 55.pct)
+ }
+ transition(duration = 0.5.s)
+ zIndex = 30
+
+ hover {
+ bottom = 4.rem
+ transform {
+ translate(tx = (-50).pct, ty = 0.pct)
+ }
+ }
+}
+
+private fun CSSBuilder.handCardStyle() {
+ classes.add("hand-card")
+ alignItems = Align.flexEnd
+ display = Display.grid
+ margin(all = 0.2.rem)
+}
+
+private fun CSSBuilder.handCardImgStyle(isPlayable: Boolean) {
+ gridRow = GridRow("1")
+ gridColumn = GridColumn("1")
+ maxWidth = 13.vw
+ maxHeight = 60.vh
+ transition(duration = 0.1.s)
+ width = 11.rem
+
+ ancestorHover(".hand-card") {
+ boxShadow(offsetX = 0.px, offsetY = 10.px, blurRadius = 40.px, color = Color.black)
+ width = 14.rem
+ maxWidth = 15.vw
+ maxHeight = 90.vh
+ }
+
+ if (!isPlayable) {
+ filter = "grayscale(50%) contrast(50%)"
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt
new file mode 100644
index 00000000..773e9835
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/game/ProductionBar.kt
@@ -0,0 +1,164 @@
+package org.luxons.sevenwonders.ui.components.game
+
+import kotlinx.css.Align
+import kotlinx.css.BorderStyle
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Color
+import kotlinx.css.Display
+import kotlinx.css.Position
+import kotlinx.css.VerticalAlign
+import kotlinx.css.alignItems
+import kotlinx.css.background
+import kotlinx.css.bottom
+import kotlinx.css.color
+import kotlinx.css.display
+import kotlinx.css.fontFamily
+import kotlinx.css.fontSize
+import kotlinx.css.height
+import kotlinx.css.margin
+import kotlinx.css.marginLeft
+import kotlinx.css.position
+import kotlinx.css.properties.borderTop
+import kotlinx.css.properties.boxShadow
+import kotlinx.css.px
+import kotlinx.css.rem
+import kotlinx.css.verticalAlign
+import kotlinx.css.vw
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import kotlinx.html.DIV
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.boards.Production
+import org.luxons.sevenwonders.model.resources.CountedResource
+import org.luxons.sevenwonders.model.resources.ResourceType
+import react.RBuilder
+import react.dom.*
+import styled.StyledDOMBuilder
+import styled.css
+import styled.styledDiv
+import styled.styledImg
+import styled.styledSpan
+
+fun RBuilder.productionBar(gold: Int, production: Production) {
+ styledDiv {
+ css {
+ productionBarStyle()
+ }
+ goldIndicator(gold)
+ fixedResources(production.fixedResources)
+ alternativeResources(production.alternativeResources)
+ }
+}
+
+private fun RBuilder.goldIndicator(amount: Int) {
+ tokenWithCount(tokenName = "coin", count = amount)
+}
+
+private fun RBuilder.fixedResources(resources: List<CountedResource>) {
+ styledDiv {
+ css {
+ margin = "auto"
+ display = Display.flex
+ }
+ resources.forEach {
+ tokenWithCount(tokenName = getTokenName(it.type), count = it.count) {
+ attrs { key = it.type.toString() }
+ css { marginLeft = 1.rem }
+ }
+ }
+ }
+}
+
+private fun RBuilder.alternativeResources(resources: Set<Set<ResourceType>>) {
+ styledDiv {
+ css {
+ margin = "auto"
+ display = Display.flex
+ }
+ resources.forEachIndexed { index, res ->
+ resourceChoice(types = res) {
+ attrs {
+ key = index.toString()
+ }
+ }
+ }
+ }
+}
+
+private fun RBuilder.resourceChoice(types: Set<ResourceType>, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ css {
+ marginLeft = (1.5).rem
+ }
+ block()
+ for ((i, t) in types.withIndex()) {
+ tokenImage(tokenName = getTokenName(t)) {
+ attrs { this.key = t.toString() }
+ }
+ if (i < types.indices.last) {
+ styledSpan { css { choiceSeparatorStyle() } }
+ }
+ }
+ }
+}
+
+private fun RBuilder.tokenWithCount(tokenName: String, count: Int, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledDiv {
+ block()
+ tokenImage(tokenName)
+ styledSpan {
+ css { tokenCountStyle() }
+ + "× $count"
+ }
+ }
+}
+
+private fun RBuilder.tokenImage(tokenName: String, block: StyledDOMBuilder<DIV>.() -> Unit = {}) {
+ styledImg(src = getTokenImagePath(tokenName)) {
+ css {
+ tokenImageStyle()
+ }
+ attrs {
+ this.title = tokenName
+ this.alt = tokenName
+ }
+ }
+}
+
+private fun getTokenImagePath(tokenName: String)= "/images/tokens/${tokenName}.png"
+
+private fun getTokenName(resourceType: ResourceType)= "resources/${resourceType.toString().toLowerCase()}"
+
+private fun CSSBuilder.productionBarStyle() {
+ alignItems = Align.center
+ background = "linear-gradient(#eaeaea, #888 7%)"
+ bottom = 0.px
+ borderTop(width = 1.px, color = Color("#8b8b8b"), style = BorderStyle.solid)
+ boxShadow(blurRadius = 15.px, color = Color("#747474"))
+ display = Display.flex
+ height = (3.5).rem
+ width = 100.vw
+ position = Position.fixed
+ zIndex = 99
+}
+
+private fun CSSBuilder.choiceSeparatorStyle() {
+ fontSize = 2.rem
+ verticalAlign = VerticalAlign.middle
+ margin(all = 5.px)
+ color = Color("#c29929")
+ declarations["text-shadow"] = "0 0 1px black"
+}
+
+private fun CSSBuilder.tokenImageStyle() {
+ height = 3.rem
+ width = 3.rem
+ verticalAlign = VerticalAlign.middle
+}
+
+private fun CSSBuilder.tokenCountStyle() {
+ fontFamily = "fantasy"
+ fontSize = 1.5.rem
+ verticalAlign = VerticalAlign.middle
+ marginLeft = 0.2.rem
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
new file mode 100644
index 00000000..876a167e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/CreateGameForm.kt
@@ -0,0 +1,76 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpInputGroup
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.JustifyContent
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.justifyContent
+import kotlinx.html.js.onSubmitFunction
+import org.luxons.sevenwonders.ui.redux.RequestCreateGame
+import org.luxons.sevenwonders.ui.redux.connectDispatch
+import org.luxons.sevenwonders.ui.utils.createElement
+import org.w3c.dom.HTMLInputElement
+import org.w3c.dom.events.Event
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+import styled.css
+import styled.styledDiv
+
+private interface CreateGameFormProps: RProps {
+ var createGame: (String) -> Unit
+}
+
+private data class CreateGameFormState(var gameName: String = ""): RState
+
+private class CreateGameForm(props: CreateGameFormProps): RComponent<CreateGameFormProps, CreateGameFormState>(props) {
+
+ override fun CreateGameFormState.init(props: CreateGameFormProps) {
+ gameName = ""
+ }
+
+ override fun RBuilder.render() {
+ styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ justifyContent = JustifyContent.spaceBetween
+ }
+ form {
+ attrs {
+ onSubmitFunction = { e -> createGame(e) }
+ }
+
+ bpInputGroup(
+ placeholder = "Game name",
+ onChange = { e ->
+ val input = e.currentTarget as HTMLInputElement
+ setState(transformState = { CreateGameFormState(input.value) })
+ },
+ rightElement = createGameButton()
+ )
+ }
+ playerInfo()
+ }
+ }
+
+ private fun createGameButton() = createElement {
+ bpButton(minimal = true, intent = Intent.PRIMARY, icon = "add", onClick = { e -> createGame(e) })
+ }
+
+ private fun createGame(e: Event) {
+ e.preventDefault() // prevents refreshing the page when pressing Enter
+ props.createGame(state.gameName)
+ }
+}
+
+val createGameForm: RClass<RProps> = connectDispatch(CreateGameForm::class) { dispatch, _ ->
+ createGame = { name -> dispatch(RequestCreateGame(name)) }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
new file mode 100644
index 00000000..2f860ca7
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameBrowser.kt
@@ -0,0 +1,10 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import react.RBuilder
+import react.dom.*
+
+fun RBuilder.gameBrowser() = div {
+ h1 { +"Games" }
+ createGameForm {}
+ gameList()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
new file mode 100644
index 00000000..47c17da1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/GameList.kt
@@ -0,0 +1,133 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import com.palantir.blueprintjs.Classes
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpIcon
+import com.palantir.blueprintjs.bpTag
+import kotlinx.css.Align
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.VerticalAlign
+import kotlinx.css.alignItems
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.verticalAlign
+import kotlinx.html.classes
+import kotlinx.html.title
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.State
+import org.luxons.sevenwonders.ui.redux.RequestJoinGame
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledSpan
+import styled.styledTr
+
+interface GameListStateProps : RProps {
+ var connectedPlayer: ConnectedPlayer
+ var games: List<LobbyDTO>
+}
+
+interface GameListDispatchProps: RProps {
+ var joinGame: (Long) -> Unit
+}
+
+interface GameListProps : GameListStateProps, GameListDispatchProps
+
+class GameListPresenter(props: GameListProps) : RComponent<GameListProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ table {
+ attrs {
+ classes = setOf(Classes.HTML_TABLE)
+ }
+ thead {
+ gameListHeaderRow()
+ }
+ tbody {
+ props.games.forEach {
+ gameListItemRow(it, props.joinGame)
+ }
+ }
+ }
+ }
+
+ private fun RBuilder.gameListHeaderRow() = tr {
+ th { +"Name" }
+ th { +"Status" }
+ th { +"Nb Players" }
+ th { +"Join" }
+ }
+
+ private fun RBuilder.gameListItemRow(lobby: LobbyDTO, joinGame: (Long) -> Unit) = styledTr {
+ css {
+ verticalAlign = VerticalAlign.middle
+ }
+ attrs {
+ key = lobby.id.toString()
+ }
+ td { +lobby.name }
+ td { gameStatus(lobby.state) }
+ td { playerCount(lobby.players.size) }
+ td { joinButton(lobby) }
+ }
+
+ private fun RBuilder.gameStatus(state: State) {
+ val intent = when(state) {
+ State.LOBBY -> Intent.SUCCESS
+ State.PLAYING -> Intent.WARNING
+ State.FINISHED -> Intent.DANGER
+ }
+ bpTag(minimal = true, intent = intent) {
+ +state.toString()
+ }
+ }
+
+ private fun RBuilder.playerCount(nPlayers: Int) {
+ styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.row
+ alignItems = Align.center
+ }
+ attrs {
+ title = "Number of players"
+ }
+ bpIcon(name = "people", title = null)
+ styledSpan {
+ +nPlayers.toString()
+ }
+ }
+ }
+
+ private fun RBuilder.joinButton(lobby: LobbyDTO) {
+ val joinability = lobby.joinability(props.connectedPlayer.displayName)
+ bpButton(
+ minimal = true,
+ title = joinability.tooltip,
+ icon = "arrow-right",
+ disabled = !joinability.canDo,
+ onClick = { props.joinGame(lobby.id) }
+ )
+ }
+}
+
+fun RBuilder.gameList() = gameList {}
+
+private val gameList = connectStateAndDispatch<GameListStateProps, GameListDispatchProps, GameListProps>(
+ clazz = GameListPresenter::class,
+ mapStateToProps = { state, _ ->
+ connectedPlayer = state.connectedPlayer ?: error("there should be a connected player")
+ games = state.games
+ },
+ mapDispatchToProps = { dispatch, _ ->
+ joinGame = { gameId -> dispatch(RequestJoinGame(gameId = gameId)) }
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
new file mode 100644
index 00000000..b939dfe1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/gameBrowser/PlayerInfo.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.components.gameBrowser
+
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.ui.redux.connectState
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+
+interface PlayerInfoProps : RProps {
+ var connectedPlayer: ConnectedPlayer?
+}
+
+class PlayerInfoPresenter(props: PlayerInfoProps) : RComponent<PlayerInfoProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ span {
+ b {
+ +"Username:"
+ }
+ props.connectedPlayer?.let {
+ + " ${it.displayName}"
+ }
+ }
+ }
+}
+
+fun RBuilder.playerInfo() = playerInfo {}
+
+private val playerInfo = connectState(
+ clazz = PlayerInfoPresenter::class,
+ mapStateToProps = { state, _ ->
+ connectedPlayer = state.connectedPlayer
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
new file mode 100644
index 00000000..1aa4be43
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/ChooseNameForm.kt
@@ -0,0 +1,64 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import com.palantir.blueprintjs.bpInputGroup
+import kotlinx.html.js.onSubmitFunction
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
+import org.luxons.sevenwonders.ui.redux.connectDispatch
+import org.luxons.sevenwonders.ui.utils.createElement
+import org.w3c.dom.HTMLInputElement
+import org.w3c.dom.events.Event
+import react.RBuilder
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.ReactElement
+import react.dom.*
+
+private interface ChooseNameFormProps: RProps {
+ var chooseUsername: (String) -> Unit
+}
+
+private data class ChooseNameFormState(var username: String = ""): RState
+
+private class ChooseNameForm(props: ChooseNameFormProps): RComponent<ChooseNameFormProps, ChooseNameFormState>(props) {
+
+ override fun ChooseNameFormState.init(props: ChooseNameFormProps) {
+ username = ""
+ }
+
+ override fun RBuilder.render() {
+ form {
+ attrs.onSubmitFunction = { e -> chooseUsername(e) }
+ bpInputGroup(
+ large = true,
+ placeholder = "Username",
+ rightElement = submitButton(),
+ onChange = { e ->
+ val input = e.currentTarget as HTMLInputElement
+ setState(transformState = { ChooseNameFormState(input.value) })
+ }
+ )
+ }
+ }
+
+ private fun submitButton(): ReactElement = createElement {
+ bpButton(
+ minimal = true,
+ icon = "arrow-right",
+ intent = Intent.PRIMARY,
+ onClick = { e -> chooseUsername(e) }
+ )
+ }
+
+ private fun chooseUsername(e: Event) {
+ e.preventDefault()
+ props.chooseUsername(state.username)
+ }
+}
+
+val chooseNameForm: RClass<RProps> = connectDispatch(ChooseNameForm::class) { dispatch, _ ->
+ chooseUsername = { name -> dispatch(RequestChooseName(name)) }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
new file mode 100644
index 00000000..43a1592b
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/Home.kt
@@ -0,0 +1,21 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import org.luxons.sevenwonders.ui.components.GlobalStyles
+import react.RBuilder
+import react.dom.*
+import styled.css
+import styled.styledDiv
+
+private const val LOGO = "images/logo-7-wonders.png"
+
+fun RBuilder.home() = styledDiv {
+ css {
+ +GlobalStyles.fullscreen
+ +HomeStyles.centerChildren
+ +HomeStyles.zeusBackground
+ }
+
+ img(src = LOGO, alt = "Seven Wonders") {}
+
+ chooseNameForm {}
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
new file mode 100644
index 00000000..624f430c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/home/HomeStyles.kt
@@ -0,0 +1,19 @@
+package org.luxons.sevenwonders.ui.components.home
+
+import kotlinx.css.*
+import styled.StyleSheet
+
+object HomeStyles : StyleSheet("HomeStyles", isStatic = true) {
+
+ val zeusBackground by css {
+ background = "url('images/background-zeus-temple.jpg') center no-repeat"
+ backgroundSize = "cover"
+ }
+
+ val centerChildren by css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ justifyContent = JustifyContent.center
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
new file mode 100644
index 00000000..5b13d8b1
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/Lobby.kt
@@ -0,0 +1,66 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpButton
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.ui.redux.RequestStartGame
+import org.luxons.sevenwonders.ui.redux.connectStateAndDispatch
+import react.RBuilder
+import react.RComponent
+import react.RProps
+import react.RState
+import react.dom.*
+
+interface LobbyStateProps : RProps {
+ var currentGame: LobbyDTO?
+ var currentPlayer: PlayerDTO?
+}
+
+interface LobbyDispatchProps : RProps {
+ var startGame: () -> Unit
+}
+
+interface LobbyProps : LobbyDispatchProps, LobbyStateProps
+
+class LobbyPresenter(props: LobbyProps) : RComponent<LobbyProps, RState>(props) {
+
+ override fun RBuilder.render() {
+ val currentGame = props.currentGame
+ val currentPlayer = props.currentPlayer
+ if (currentGame == null || currentPlayer == null) {
+ div { +"Error: no current game." }
+ return
+ }
+ div {
+ h2 { +"${currentGame.name} — Lobby" }
+ radialPlayerList(currentGame.players, currentPlayer)
+ if (currentPlayer.isGameOwner) {
+ val startability = currentGame.startability(currentPlayer.username)
+ bpButton(
+ large = true,
+ intent = Intent.PRIMARY,
+ icon = "play",
+ title = startability.tooltip,
+ disabled = !startability.canDo,
+ onClick = { props.startGame() }
+ ) {
+ + "START"
+ }
+ }
+ }
+ }
+}
+
+fun RBuilder.lobby() = lobby {}
+
+private val lobby = connectStateAndDispatch<LobbyStateProps, LobbyDispatchProps, LobbyProps>(
+ clazz = LobbyPresenter::class,
+ mapStateToProps = { state, _ ->
+ currentGame = state.currentLobby
+ currentPlayer = state.currentPlayer
+ },
+ mapDispatchToProps = { dispatch, _ ->
+ startGame = { dispatch(RequestStartGame()) }
+ }
+)
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
new file mode 100644
index 00000000..be3bb1de
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialList.kt
@@ -0,0 +1,121 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import kotlinx.css.CSSBuilder
+import kotlinx.css.Display
+import kotlinx.css.ListStyleType
+import kotlinx.css.Position
+import kotlinx.css.display
+import kotlinx.css.height
+import kotlinx.css.left
+import kotlinx.css.listStyleType
+import kotlinx.css.margin
+import kotlinx.css.padding
+import kotlinx.css.pct
+import kotlinx.css.position
+import kotlinx.css.properties.Timing
+import kotlinx.css.properties.ms
+import kotlinx.css.properties.transform
+import kotlinx.css.properties.transition
+import kotlinx.css.properties.translate
+import kotlinx.css.px
+import kotlinx.css.top
+import kotlinx.css.width
+import kotlinx.css.zIndex
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledLi
+import styled.styledUl
+
+typealias ElementBuilder = RBuilder.() -> ReactElement
+
+fun RBuilder.radialList(
+ itemBuilders: List<ElementBuilder>,
+ centerElementBuilder: ElementBuilder,
+ itemWidth: Int,
+ itemHeight: Int,
+ options: RadialConfig = RadialConfig()
+): ReactElement {
+ val containerWidth = options.diameter + itemWidth
+ val containerHeight = options.diameter + itemHeight
+
+ return styledDiv {
+ css {
+ zeroMargins()
+ position = Position.relative
+ width = containerWidth.px
+ height = containerHeight.px
+ }
+ radialListItems(itemBuilders, options)
+ radialListCenter(centerElementBuilder)
+ }
+}
+
+private fun RBuilder.radialListItems(itemBuilders: List<ElementBuilder>, radialConfig: RadialConfig): ReactElement {
+ val offsets = offsetsFromCenter(itemBuilders.size, radialConfig)
+ return styledUl {
+ css {
+ zeroMargins()
+ transition(property = "all", duration = 500.ms, timing = Timing.easeInOut)
+ zIndex = 1
+ width = radialConfig.diameter.px
+ height = radialConfig.diameter.px
+ absoluteCenter()
+ }
+ itemBuilders.forEachIndexed { i, itemBuilder ->
+ radialListItem(itemBuilder, i, offsets[i])
+ }
+ }
+}
+
+private fun RBuilder.radialListItem(itemBuilder: ElementBuilder, i: Int, offset: CartesianCoords): ReactElement {
+ return styledLi {
+ css {
+ display = Display.block
+ position = Position.absolute
+ top = 50.pct
+ left = 50.pct
+ zeroMargins()
+ listStyleType = ListStyleType.unset
+ transition("all", 500.ms, Timing.easeInOut)
+ zIndex = 1
+ transform {
+ translate(offset.x.px, offset.y.px)
+ translate((-50).pct, (-50).pct)
+ }
+ }
+ attrs {
+ key = "$i"
+ }
+ itemBuilder()
+ }
+}
+
+private fun RBuilder.radialListCenter(centerElement: ElementBuilder?): ReactElement? {
+ if (centerElement == null) {
+ return null
+ }
+ return styledDiv {
+ css {
+ zIndex = 0
+ absoluteCenter()
+ }
+ centerElement()
+ }
+}
+
+private fun CSSBuilder.absoluteCenter() {
+ position = Position.absolute
+ left = 50.pct
+ top = 50.pct
+ transform {
+ translate((-50).pct, (-50).pct)
+ }
+}
+
+private fun CSSBuilder.zeroMargins() {
+ margin = "0"
+ padding = "0"
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
new file mode 100644
index 00000000..d668ab9b
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialMath.kt
@@ -0,0 +1,57 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+data class CartesianCoords(
+ val x: Int,
+ val y: Int
+)
+
+data class PolarCoords(
+ val radius: Int,
+ val angleDeg: Int
+)
+
+private fun Int.toRadians() = (this * PI / 180.0)
+private fun Double.project(angleRad: Double, trigFn: (Double) -> Double) = (this * trigFn(angleRad)).roundToInt()
+private fun Double.xProjection(angleRad: Double) = project(angleRad, ::cos)
+private fun Double.yProjection(angleRad: Double) = project(angleRad, ::sin)
+
+private fun PolarCoords.toCartesian() = CartesianCoords(
+ x = radius.toDouble().xProjection(angleDeg.toRadians()),
+ y = radius.toDouble().yProjection(angleDeg.toRadians())
+)
+
+// Y-axis is pointing down in the browser, so the directions need to be reversed
+// (positive angles are now clockwise)
+enum class Direction(private val value: Int) {
+ CLOCKWISE(1),
+ COUNTERCLOCKWISE(-1);
+
+ fun toOrientedDegrees(deg: Int) = value * deg
+}
+
+data class RadialConfig(
+ val radius: Int = 120,
+ val spreadArcDegrees: Int = 360, // full circle
+ val firstItemAngleDegrees: Int = 0, // 12 o'clock
+ val direction: Direction = Direction.CLOCKWISE
+) {
+ val diameter: Int = radius * 2
+}
+
+private const val DEFAULT_START = -90 // Up, because Y-axis is reversed
+
+fun offsetsFromCenter(nbItems: Int, radialConfig: RadialConfig = RadialConfig()): List<CartesianCoords> {
+ val startAngle = DEFAULT_START + radialConfig.direction.toOrientedDegrees(radialConfig.firstItemAngleDegrees)
+ val angleStep = radialConfig.spreadArcDegrees / nbItems
+ return List(nbItems) { itemCartesianOffsets(startAngle, angleStep, it, radialConfig) }
+}
+
+private fun itemCartesianOffsets(startAngle: Int, angleStep: Int, index: Int, config: RadialConfig): CartesianCoords {
+ val itemAngle = startAngle + config.direction.toOrientedDegrees(angleStep) * index
+ return PolarCoords(config.radius, itemAngle).toCartesian()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
new file mode 100644
index 00000000..ff541696
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/components/lobby/RadialPlayerList.kt
@@ -0,0 +1,106 @@
+package org.luxons.sevenwonders.ui.components.lobby
+
+import com.palantir.blueprintjs.IconName
+import com.palantir.blueprintjs.Intent
+import com.palantir.blueprintjs.bpIcon
+import kotlinx.css.Align
+import kotlinx.css.Display
+import kotlinx.css.FlexDirection
+import kotlinx.css.alignItems
+import kotlinx.css.display
+import kotlinx.css.flexDirection
+import kotlinx.css.margin
+import kotlinx.css.opacity
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+import styled.css
+import styled.styledDiv
+import styled.styledH5
+
+fun RBuilder.radialPlayerList(players: List<PlayerDTO>, currentPlayer: PlayerDTO): ReactElement {
+ val playerItemBuilders = players
+ .growTo(targetSize = 3)
+ .withUserFirst(currentPlayer)
+ .map { p -> p.elementBuilder(p?.username == currentPlayer.username) }
+
+ val tableImgBuilder: ElementBuilder = { roundTableImg() }
+
+ return radialList(
+ itemBuilders = playerItemBuilders,
+ centerElementBuilder = tableImgBuilder,
+ itemWidth = 120,
+ itemHeight = 100,
+ options = RadialConfig(
+ radius = 175,
+ firstItemAngleDegrees = 180 // self at the bottom
+ )
+ )
+}
+
+private fun RBuilder.roundTableImg(): ReactElement = img {
+ attrs {
+ src = "images/round-table.png"
+ alt = "Round table"
+ width = "200"
+ height = "200"
+ }
+}
+
+private fun List<PlayerDTO?>.withUserFirst(me: PlayerDTO): List<PlayerDTO?> {
+ val nonUsersBeginning = takeWhile { it?.username != me.username }
+ val userToEnd = subList(nonUsersBeginning.size, size)
+ return userToEnd + nonUsersBeginning
+}
+
+private fun <T> List<T>.growTo(targetSize: Int): List<T?> {
+ if (size >= targetSize) return this
+ return this + List(targetSize - size) { null }
+}
+
+private fun PlayerDTO?.elementBuilder(isMe: Boolean): ElementBuilder {
+ if (this == null) {
+ return { playerPlaceholder() }
+ } else {
+ return { playerItem(this@elementBuilder, isMe) }
+ }
+}
+
+private fun RBuilder.playerItem(player: PlayerDTO, isMe: Boolean): ReactElement = styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ }
+ val title = if (player.isGameOwner) "Game owner" else null
+ userIcon(isMe = isMe, isOwner = player.isGameOwner, title = title)
+ styledH5 {
+ css {
+ margin = "0"
+ }
+ +player.displayName
+ }
+}
+
+private fun RBuilder.playerPlaceholder(): ReactElement = styledDiv {
+ css {
+ display = Display.flex
+ flexDirection = FlexDirection.column
+ alignItems = Align.center
+ opacity = 0.3
+ }
+ userIcon(isMe = false, isOwner = false, title = "Waiting for player...")
+ styledH5 {
+ css {
+ margin = "0"
+ }
+ +"?"
+ }
+}
+
+private fun RBuilder.userIcon(isMe: Boolean, isOwner: Boolean, title: String?): ReactElement {
+ val iconName: IconName = if (isOwner) "badge" else "user"
+ val intent: Intent = if (isMe) Intent.WARNING else Intent.NONE
+ return bpIcon(name = iconName, intent = intent, size = 50, title = title)
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
new file mode 100644
index 00000000..3e3de561
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Actions.kt
@@ -0,0 +1,26 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.cards.PreparedCard
+import redux.RAction
+
+data class SetCurrentPlayerAction(val player: ConnectedPlayer): RAction
+
+data class UpdateGameListAction(val games: List<LobbyDTO>): RAction
+
+data class UpdateLobbyAction(val lobby: LobbyDTO): RAction
+
+data class EnterLobbyAction(val lobby: LobbyDTO): RAction
+
+data class EnterGameAction(val lobby: LobbyDTO, val turnInfo: PlayerTurnInfo): RAction
+
+data class TurnInfoEvent(val turnInfo: PlayerTurnInfo): RAction
+
+data class PreparedMoveEvent(val move: PlayerMove): RAction
+
+data class PreparedCardEvent(val card: PreparedCard): RAction
+
+data class PlayerReadyEvent(val username: String): RAction
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
new file mode 100644
index 00000000..836f5b4e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/ApiActions.kt
@@ -0,0 +1,23 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.CustomizableSettings
+import org.luxons.sevenwonders.model.PlayerMove
+import redux.RAction
+
+data class RequestChooseName(val playerName: String): RAction
+
+data class RequestCreateGame(val gameName: String): RAction
+
+data class RequestJoinGame(val gameId: Long): RAction
+
+data class RequestReorderPlayers(val orderedPlayers: List<String>): RAction
+
+data class RequestUpdateSettings(val settings: CustomizableSettings): RAction
+
+class RequestStartGame: RAction
+
+class RequestSayReady: RAction
+
+data class RequestPrepareMove(val move: PlayerMove): RAction
+
+class RequestUnprepareMove: RAction
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
new file mode 100644
index 00000000..c21f6deb
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Reducers.kt
@@ -0,0 +1,84 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.model.PlayerMove
+import org.luxons.sevenwonders.model.PlayerTurnInfo
+import org.luxons.sevenwonders.model.api.ConnectedPlayer
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.model.api.PlayerDTO
+import org.luxons.sevenwonders.model.api.State
+import org.luxons.sevenwonders.model.cards.CardBack
+import org.luxons.sevenwonders.model.cards.HandCard
+import redux.RAction
+
+data class SwState(
+ val connectedPlayer: ConnectedPlayer? = null,
+ // they must be by ID to support updates to a sublist
+ val gamesById: Map<Long, LobbyDTO> = emptyMap(),
+ val currentLobby: LobbyDTO? = null,
+ val gameState: GameState? = null
+) {
+ val currentPlayer: PlayerDTO? = (gameState?.players ?: currentLobby?.players)?.first {
+ it.username == connectedPlayer?.username
+ }
+ val games: List<LobbyDTO> = gamesById.values.toList()
+}
+
+data class GameState(
+ val id: Long,
+ val players: List<PlayerDTO>,
+ val turnInfo: PlayerTurnInfo?,
+ val preparedCardsByUsername: Map<String, CardBack?> = emptyMap(),
+ val currentPreparedMove: PlayerMove? = null
+) {
+ val currentPreparedCard: HandCard?
+ get() = turnInfo?.hand?.firstOrNull { it.name == currentPreparedMove?.cardName }
+}
+
+fun rootReducer(state: SwState, action: RAction): SwState = state.copy(
+ gamesById = gamesReducer(state.gamesById, action),
+ connectedPlayer = currentPlayerReducer(state.connectedPlayer, action),
+ currentLobby = currentLobbyReducer(state.currentLobby, action),
+ gameState = gameStateReducer(state.gameState, action)
+)
+
+private fun gamesReducer(games: Map<Long, LobbyDTO>, action: RAction): Map<Long, LobbyDTO> = when (action) {
+ is UpdateGameListAction -> (games + action.games.associateBy { it.id }).filterValues { it.state != State.FINISHED }
+ else -> games
+}
+
+private fun currentPlayerReducer(currentPlayer: ConnectedPlayer?, action: RAction): ConnectedPlayer? = when (action) {
+ is SetCurrentPlayerAction -> action.player
+ else -> currentPlayer
+}
+
+private fun currentLobbyReducer(currentLobby: LobbyDTO?, action: RAction): LobbyDTO? = when (action) {
+ is EnterLobbyAction -> action.lobby
+ is UpdateLobbyAction -> action.lobby
+ is PlayerReadyEvent -> currentLobby?.let { l ->
+ l.copy(players = l.players.map { p ->
+ if (p.username == action.username) p.copy(isReady = true) else p
+ })
+ }
+ else -> currentLobby
+}
+
+private fun gameStateReducer(gameState: GameState?, action: RAction): GameState? = when (action) {
+ is EnterGameAction -> GameState(
+ id = action.lobby.id,
+ players = action.lobby.players,
+ turnInfo = action.turnInfo
+ )
+ is PreparedMoveEvent -> gameState?.copy(currentPreparedMove = action.move)
+ is RequestUnprepareMove -> gameState?.copy(currentPreparedMove = null)
+ is PreparedCardEvent -> gameState?.copy(
+ preparedCardsByUsername = gameState.preparedCardsByUsername + (action.card.player.username to action.card.cardBack)
+ )
+ is PlayerReadyEvent -> gameState?.copy(players = gameState.players.map { p ->
+ if (p.username == action.username) p.copy(isReady = true) else p
+ })
+ is TurnInfoEvent -> gameState?.copy(
+ players = gameState.players.map { p -> p.copy(isReady = false) },
+ turnInfo = action.turnInfo
+ )
+ else -> gameState
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
new file mode 100644
index 00000000..6f50a627
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Store.kt
@@ -0,0 +1,29 @@
+package org.luxons.sevenwonders.ui.redux
+
+import org.luxons.sevenwonders.ui.redux.sagas.SagaManager
+import redux.RAction
+import redux.Store
+import redux.WrapperAction
+import redux.applyMiddleware
+import redux.compose
+import redux.createStore
+import redux.rEnhancer
+import kotlin.browser.window
+
+val INITIAL_STATE = SwState()
+
+private fun <A, T1, R> composeWithDevTools(function1: (T1) -> R, function2: (A) -> T1): (A) -> R {
+ val reduxDevtoolsExtensionCompose = window.asDynamic().__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
+ if (reduxDevtoolsExtensionCompose == undefined) {
+ return compose(function1, function2)
+ }
+ return reduxDevtoolsExtensionCompose(function1, function2) as Function1<A, R>
+}
+
+fun configureStore(
+ sagaManager: SagaManager<SwState, RAction, WrapperAction>,
+ initialState: SwState = INITIAL_STATE
+): Store<SwState, RAction, WrapperAction> {
+ val sagaEnhancer = applyMiddleware(sagaManager.createMiddleware())
+ return createStore(::rootReducer, initialState, composeWithDevTools(sagaEnhancer, rEnhancer()))
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
new file mode 100644
index 00000000..67ac5304
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/Utils.kt
@@ -0,0 +1,39 @@
+package org.luxons.sevenwonders.ui.redux
+
+import react.RClass
+import react.RComponent
+import react.RProps
+import react.RState
+import react.invoke
+import react.redux.rConnect
+import redux.RAction
+import redux.WrapperAction
+import kotlin.reflect.KClass
+
+inline fun <reified DP : RProps> connectDispatch(
+ clazz: KClass<out RComponent<DP, out RState>>,
+ noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect(mapDispatchToProps = mapDispatchToProps)
+ return connect.invoke(clazz.js.unsafeCast<RClass<DP>>())
+}
+
+inline fun <reified SP : RProps> connectState(
+ clazz: KClass<out RComponent<SP, out RState>>,
+ noinline mapStateToProps: SP.(SwState, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect(mapStateToProps = mapStateToProps)
+ return connect.invoke(clazz.js.unsafeCast<RClass<SP>>())
+}
+
+inline fun <reified SP : RProps, reified DP : RProps, reified P : RProps> connectStateAndDispatch(
+ clazz: KClass<out RComponent<P, out RState>>,
+ noinline mapStateToProps: SP.(SwState, RProps) -> Unit,
+ noinline mapDispatchToProps: DP.((RAction) -> WrapperAction, RProps) -> Unit
+): RClass<RProps> {
+ val connect = rConnect<SwState, RAction, WrapperAction, RProps, SP, DP, P>(
+ mapStateToProps = mapStateToProps,
+ mapDispatchToProps = mapDispatchToProps
+ )
+ return connect.invoke(clazz.js.unsafeCast<RClass<P>>())
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
new file mode 100644
index 00000000..7806bc98
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameBrowserSagas.kt
@@ -0,0 +1,50 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.hildan.krossbow.stomp.StompSubscription
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.ui.redux.EnterLobbyAction
+import org.luxons.sevenwonders.ui.redux.RequestCreateGame
+import org.luxons.sevenwonders.ui.redux.RequestJoinGame
+import org.luxons.sevenwonders.ui.redux.UpdateGameListAction
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.Route
+import org.luxons.sevenwonders.ui.utils.awaitFirst
+
+suspend fun SwSagaContext.gameBrowserSaga(session: SevenWondersSession) {
+ GameBrowserSaga(session, this).run()
+}
+
+private class GameBrowserSaga(
+ private val session: SevenWondersSession,
+ private val sagaContext: SwSagaContext
+) {
+ suspend fun run() {
+ coroutineScope {
+ val gamesSubscription = session.watchGames()
+ launch { dispatchGameUpdates(gamesSubscription) }
+ val lobby = awaitCreateOrJoinGame()
+ gamesSubscription.unsubscribe()
+ sagaContext.dispatch(EnterLobbyAction(lobby))
+ sagaContext.dispatch(Navigate(Route.LOBBY))
+ }
+ }
+
+ private suspend fun dispatchGameUpdates(gamesSubscription: StompSubscription<List<LobbyDTO>>) {
+ sagaContext.dispatchAll(gamesSubscription.messages) { UpdateGameListAction(it) }
+ }
+
+ private suspend fun awaitCreateOrJoinGame(): LobbyDTO = awaitFirst(this::awaitCreateGame, this::awaitJoinGame)
+
+ private suspend fun awaitCreateGame(): LobbyDTO {
+ val action = sagaContext.next<RequestCreateGame>()
+ return session.createGame(action.gameName)
+ }
+
+ private suspend fun awaitJoinGame(): LobbyDTO {
+ val action = sagaContext.next<RequestJoinGame>()
+ return session.joinGame(action.gameId)
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
new file mode 100644
index 00000000..a9c2ca2c
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/GameSagas.kt
@@ -0,0 +1,36 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.PlayerReadyEvent
+import org.luxons.sevenwonders.ui.redux.PreparedCardEvent
+import org.luxons.sevenwonders.ui.redux.PreparedMoveEvent
+import org.luxons.sevenwonders.ui.redux.RequestPrepareMove
+import org.luxons.sevenwonders.ui.redux.RequestSayReady
+import org.luxons.sevenwonders.ui.redux.RequestUnprepareMove
+import org.luxons.sevenwonders.ui.redux.TurnInfoEvent
+
+suspend fun SwSagaContext.gameSaga(session: SevenWondersSession) {
+ val game = getState().gameState ?: error("Game saga run without a current game")
+ coroutineScope {
+ val playerReadySub = session.watchPlayerReady(game.id)
+ val preparedCardsSub = session.watchPreparedCards(game.id)
+ val turnInfoSub = session.watchTurns()
+ val sayReadyJob = launch { onEach<RequestSayReady> { session.sayReady() } }
+ val unprepareJob = launch { onEach<RequestUnprepareMove> { session.unprepareMove() } }
+ val prepareMoveJob = launch {
+ onEach<RequestPrepareMove> {
+ val move = session.prepareMove(it.move)
+ dispatch(PreparedMoveEvent(move))
+ }
+ }
+ launch { dispatchAll(playerReadySub.messages) { PlayerReadyEvent(it) } }
+ launch { dispatchAll(preparedCardsSub.messages) { PreparedCardEvent(it) } }
+ launch { dispatchAll(turnInfoSub.messages) { TurnInfoEvent(it) } }
+ // TODO await game end
+ // TODO unsubscribe all subs, cancel all jobs
+ }
+ console.log("End of game saga")
+}
+
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
new file mode 100644
index 00000000..678276dc
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/LobbySagas.kt
@@ -0,0 +1,42 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.hildan.krossbow.stomp.StompSubscription
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.model.api.LobbyDTO
+import org.luxons.sevenwonders.ui.redux.EnterGameAction
+import org.luxons.sevenwonders.ui.redux.RequestStartGame
+import org.luxons.sevenwonders.ui.redux.UpdateLobbyAction
+import org.luxons.sevenwonders.ui.router.Navigate
+import org.luxons.sevenwonders.ui.router.Route
+
+suspend fun SwSagaContext.lobbySaga(session: SevenWondersSession) {
+ val lobby = getState().currentLobby ?: error("Lobby saga run without a current lobby")
+ coroutineScope {
+ val lobbyUpdatesSubscription = session.watchLobbyUpdates()
+ launch { watchLobbyUpdates(lobbyUpdatesSubscription) }
+ val startGameJob = launch { awaitStartGame(session) }
+
+ awaitGameStart(session, lobby.id)
+
+ lobbyUpdatesSubscription.unsubscribe()
+ startGameJob.cancel()
+ dispatch(Navigate(Route.GAME))
+ }
+}
+
+private suspend fun SwSagaContext.watchLobbyUpdates(lobbyUpdatesSubscription: StompSubscription<LobbyDTO>) {
+ dispatchAll(lobbyUpdatesSubscription.messages) { UpdateLobbyAction(it) }
+}
+
+private suspend fun SwSagaContext.awaitGameStart(session: SevenWondersSession, lobbyId: Long) {
+ val turnInfo = session.awaitGameStart(lobbyId)
+ val lobby = getState().currentLobby!!
+ dispatch(EnterGameAction(lobby, turnInfo))
+}
+
+private suspend fun SwSagaContext.awaitStartGame(session: SevenWondersSession) {
+ next<RequestStartGame>()
+ session.startGame()
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
new file mode 100644
index 00000000..c4a92581
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/Sagas.kt
@@ -0,0 +1,54 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import org.luxons.sevenwonders.client.SevenWondersClient
+import org.luxons.sevenwonders.client.SevenWondersSession
+import org.luxons.sevenwonders.ui.redux.RequestChooseName
+import org.luxons.sevenwonders.ui.redux.SetCurrentPlayerAction
+import org.luxons.sevenwonders.ui.redux.SwState
+import org.luxons.sevenwonders.ui.router.Route
+import org.luxons.sevenwonders.ui.router.routerSaga
+import redux.RAction
+import redux.WrapperAction
+
+typealias SwSagaContext = SagaContext<SwState, RAction, WrapperAction>
+
+suspend fun SwSagaContext.rootSaga() = coroutineScope {
+ val action = next<RequestChooseName>()
+ val session = SevenWondersClient().connect("localhost:8000")
+ console.info("Connected to Seven Wonders web socket API")
+
+ launch {
+ serverErrorSaga(session)
+ }
+ yield() // ensures the error saga starts
+
+ val player = session.chooseName(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+
+ routerSaga(Route.GAME_BROWSER) {
+ when (it) {
+ Route.HOME -> homeSaga(session)
+ Route.LOBBY -> lobbySaga(session)
+ Route.GAME_BROWSER -> gameBrowserSaga(session)
+ Route.GAME -> gameSaga(session)
+ }
+ }
+}
+
+private suspend fun serverErrorSaga(session: SevenWondersSession) {
+ val errorsSub = session.watchErrors()
+ for (err in errorsSub.messages) {
+ // TODO use blueprintjs toaster
+ console.error("${err.code}: ${err.message}")
+ console.error(JSON.stringify(err))
+ }
+}
+
+private suspend fun SwSagaContext.homeSaga(session: SevenWondersSession) {
+ val action = next<RequestChooseName>()
+ val player = session.chooseName(action.playerName)
+ dispatch(SetCurrentPlayerAction(player))
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
new file mode 100644
index 00000000..1a57708e
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/redux/sagas/SagasFramework.kt
@@ -0,0 +1,137 @@
+package org.luxons.sevenwonders.ui.redux.sagas
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BroadcastChannel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.launch
+import redux.Middleware
+import redux.MiddlewareApi
+import redux.RAction
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SagaManager<S, A : RAction, R>(
+ private val monitor: ((A) -> Unit)? = null
+) {
+ private lateinit var context: SagaContext<S, A, R>
+
+ private val actions = BroadcastChannel<A>(16)
+
+ fun createMiddleware(): Middleware<S, A, R, A, R> = ::sagasMiddleware
+
+ private fun sagasMiddleware(api: MiddlewareApi<S, A, R>): ((A) -> R) -> (A) -> R {
+ context = SagaContext(api, actions)
+ return { nextDispatch ->
+ { action ->
+ onActionDispatched(action)
+ val result = nextDispatch(action)
+ handleAction(action)
+ result
+ }
+ }
+ }
+
+ private fun onActionDispatched(action: A) {
+ monitor?.invoke(action)
+ }
+
+ private fun handleAction(action: A) {
+ GlobalScope.launch { actions.send(action) }
+ }
+
+ fun launchSaga(coroutineScope: CoroutineScope, saga: suspend SagaContext<S, A, R>.() -> Unit): Job {
+ checkMiddlewareApplied()
+ return coroutineScope.launch {
+ context.saga()
+ }
+ }
+
+ suspend fun runSaga(saga: suspend SagaContext<S, A, R>.() -> Unit) {
+ checkMiddlewareApplied()
+ context.saga()
+ }
+
+ private fun checkMiddlewareApplied() {
+ check(::context.isInitialized) {
+ "Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware"
+ }
+ }
+}
+
+@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
+class SagaContext<S, A : RAction, R>(
+ private val reduxApi: MiddlewareApi<S, A, R>,
+ private val actions: BroadcastChannel<A>
+) {
+ /**
+ * Gets the current redux state.
+ */
+ fun getState(): S = reduxApi.getState()
+
+ /**
+ * Dispatches the given redux [action].
+ */
+ fun dispatch(action: A) {
+ reduxApi.dispatch(action)
+ }
+
+ /**
+ * Dispatches an action given by [createAction] for each message received in [channel].
+ */
+ suspend fun <T> dispatchAll(channel: ReceiveChannel<T>, createAction: (T) -> A) {
+ for (msg in channel) {
+ reduxApi.dispatch(createAction(msg))
+ }
+ }
+
+ /**
+ * Executes [handle] on every action dispatched. This runs forever until the current coroutine is cancelled.
+ */
+ suspend fun onEach(handle: suspend SagaContext<S, A, R>.(A) -> Unit) {
+ val channel = actions.openSubscription()
+ try {
+ for (a in channel) {
+ handle(a)
+ }
+ } finally {
+ channel.cancel()
+ }
+ }
+
+ /**
+ * Executes [handle] on every action dispatched of the type [T]. This runs forever until the current coroutine is
+ * cancelled.
+ */
+ suspend inline fun <reified T : A> onEach(
+ crossinline handle: suspend SagaContext<S, A, R>.(T) -> Unit
+ ) = onEach {
+ if (it is T) {
+ handle(it)
+ }
+ }
+
+ /**
+ * Suspends until the next action matching the given [predicate] is dispatched, and returns that action.
+ */
+ suspend fun next(predicate: (A) -> Boolean): A {
+ val channel = actions.openSubscription()
+ try {
+ for (a in channel) {
+ if (predicate(a)) {
+ return a
+ }
+ }
+ } finally {
+ channel.cancel()
+ }
+ error("Actions channel closed before receiving a matching action")
+ }
+
+ /**
+ * Suspends until the next action of type [T] is dispatched, and returns that action.
+ */
+ suspend inline fun <reified T : A> next(): T = next { it is T } as T
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
new file mode 100644
index 00000000..19e8bd94
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/router/Router.kt
@@ -0,0 +1,32 @@
+package org.luxons.sevenwonders.ui.router
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.luxons.sevenwonders.ui.redux.sagas.SwSagaContext
+import redux.RAction
+import kotlin.browser.window
+
+enum class Route(val path: String) {
+ HOME("/"),
+ GAME_BROWSER("/games"),
+ LOBBY("/lobby"),
+ GAME("/game"),
+}
+
+data class Navigate(val route: Route): RAction
+
+suspend fun SwSagaContext.routerSaga(
+ startRoute: Route,
+ runRouteSaga: suspend SwSagaContext.(Route) -> Unit
+) {
+ coroutineScope {
+ window.location.hash = startRoute.path
+ var currentSaga: Job = launch { runRouteSaga(startRoute) }
+ onEach<Navigate> {
+ currentSaga.cancel()
+ window.location.hash = it.route.path
+ currentSaga = launch { runRouteSaga(it.route) }
+ }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
new file mode 100644
index 00000000..600f08d3
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/CoroutinesUtils.kt
@@ -0,0 +1,15 @@
+package org.luxons.sevenwonders.ui.utils
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.selects.select
+
+// Cannot inline or it crashes for some reason
+suspend fun <R> awaitFirst(f1: suspend () -> R, f2: suspend () -> R): R = coroutineScope {
+ val deferred1 = async { f1() }
+ val deferred2 = async { f2() }
+ select<R> {
+ deferred1.onAwait { deferred2.cancel(); it }
+ deferred2.onAwait { deferred1.cancel(); it }
+ }
+}
diff --git a/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt
new file mode 100644
index 00000000..07b3f2b5
--- /dev/null
+++ b/sw-ui/src/main/kotlin/org/luxons/sevenwonders/ui/utils/ReactUtils.kt
@@ -0,0 +1,16 @@
+package org.luxons.sevenwonders.ui.utils
+
+import kotlinx.html.SPAN
+import kotlinx.html.attributesMapOf
+import react.RBuilder
+import react.ReactElement
+import react.dom.*
+
+/**
+ * Creates a ReactElement without appending it (so that is can be passed around).
+ */
+fun createElement(block: RBuilder.() -> ReactElement): ReactElement {
+ return RDOMBuilder { SPAN(attributesMapOf("class", null), it) }
+ .apply { block() }
+ .create()
+}
bgstack15